Formatting
publish / publish (push) Successful in 11m55s

This commit is contained in:
khodowany
2026-09-14 23:54:30 -05:00
parent b9d30fb975
commit 097cb61d2d
33 changed files with 950 additions and 302 deletions
+5 -1
View File
@@ -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
+14 -2
View File
@@ -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.
+1 -21
View File
@@ -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"]
+10 -9
View File
@@ -8,6 +8,7 @@
#include <modulus/error.hpp>
#include <modulus/log.hpp>
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<modulus::logging_config, modulus::error>;
auto parse_error_config(boost::json::value const& value) -> std::expected<modulus::error_config, modulus::error>;
auto parse_config(boost::json::value const& value) -> std::expected<modulus::config, modulus::error>;
auto load_config(std::string const& path) -> std::expected<modulus::config, modulus::error>;
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<modulus::logging_config, modulus::error>;
auto parse_error_config(boost::json::value const &value) -> std::expected<modulus::error_config, modulus::error>;
auto parse_config(boost::json::value const &value) -> std::expected<modulus::config, modulus::error>;
auto load_config(std::string const &path) -> std::expected<modulus::config, modulus::error>;
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;
}
+5 -4
View File
@@ -7,6 +7,7 @@
#include <modulus/source_location.hpp>
#include <modulus/types.hpp>
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;
+12 -12
View File
@@ -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<boost::json::value, modulus::error>;
auto require_object(boost::json::value const& value, std::string_view context, modulus::json_config::options config = {}) -> std::expected<boost::json::object const*, modulus::error>;
auto reject_unknown_fields(boost::json::object const& object, std::initializer_list<std::string_view> allowed, std::string_view context, modulus::json_config::options config = {}) -> std::expected<void, modulus::error>;
auto optional_bool_field(boost::json::object const& object, std::string_view name, std::bool_t fallback, modulus::json_config::options config = {}) -> std::expected<std::bool_t, modulus::error>;
auto optional_string_field(boost::json::object const& object, std::string_view name, std::string fallback, modulus::json_config::options config = {}) -> std::expected<std::string, modulus::error>;
auto required_string_field(boost::json::object const& object, std::string_view name, std::string_view context, modulus::json_config::options config = {}) -> std::expected<std::string, modulus::error>;
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<std::uint64_t, modulus::error>;
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<std::uint64_t, modulus::error>;
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<std::int32_t, modulus::error>;
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::uint64_t>(std::numeric_limits<std::int64_t>::max()), modulus::json_config::options config = {}) -> std::expected<std::chrono::milliseconds, modulus::error>;
auto load_value(std::string const &path, modulus::json_config::options config = {}) -> std::expected<boost::json::value, modulus::error>;
auto require_object(boost::json::value const &value, std::string_view context, modulus::json_config::options config = {}) -> std::expected<boost::json::object const *, modulus::error>;
auto reject_unknown_fields(boost::json::object const &object, std::initializer_list<std::string_view> allowed, std::string_view context, modulus::json_config::options config = {}) -> std::expected<void, modulus::error>;
auto optional_bool_field(boost::json::object const &object, std::string_view name, std::bool_t fallback, modulus::json_config::options config = {}) -> std::expected<std::bool_t, modulus::error>;
auto optional_string_field(boost::json::object const &object, std::string_view name, std::string fallback, modulus::json_config::options config = {}) -> std::expected<std::string, modulus::error>;
auto required_string_field(boost::json::object const &object, std::string_view name, std::string_view context, modulus::json_config::options config = {}) -> std::expected<std::string, modulus::error>;
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<std::uint64_t, modulus::error>;
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<std::uint64_t, modulus::error>;
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<std::int32_t, modulus::error>;
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::uint64_t>(std::numeric_limits<std::int64_t>::max()), modulus::json_config::options config = {}) -> std::expected<std::chrono::milliseconds, modulus::error>;
template <typename ENUM>
auto parse_enum(std::string_view value, std::initializer_list<modulus::json_config::enum_mapping<ENUM>> mappings, std::string_view message, modulus::json_config::options config = {}) -> std::expected<ENUM, modulus::error>;
template <typename ENUM>
auto optional_enum_field(boost::json::object const& object, std::string_view name, ENUM fallback, std::initializer_list<modulus::json_config::enum_mapping<ENUM>> mappings, std::string_view message, modulus::json_config::options config = {}) -> std::expected<ENUM, modulus::error>;
auto optional_enum_field(boost::json::object const &object, std::string_view name, ENUM fallback, std::initializer_list<modulus::json_config::enum_mapping<ENUM>> mappings, std::string_view message, modulus::json_config::options config = {}) -> std::expected<ENUM, modulus::error>;
}
@@ -60,7 +60,7 @@ auto modulus::json_config::parse_enum(std::string_view value, std::initializer_l
}
template <typename ENUM>
auto modulus::json_config::optional_enum_field(boost::json::object const& object, std::string_view name, ENUM fallback, std::initializer_list<modulus::json_config::enum_mapping<ENUM>> mappings, std::string_view message, modulus::json_config::options config) -> std::expected<ENUM, modulus::error> {
auto modulus::json_config::optional_enum_field(boost::json::object const &object, std::string_view name, ENUM fallback, std::initializer_list<modulus::json_config::enum_mapping<ENUM>> mappings, std::string_view message, modulus::json_config::options config) -> std::expected<ENUM, modulus::error> {
auto found = object.find(name);
if (found == object.end()) {
return fallback;
+8 -7
View File
@@ -13,6 +13,7 @@
#include <modulus/source_location.hpp>
#include <modulus/types.hpp>
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<void(const modulus::log_record&)> sink) -> void;
auto set_sink(std::function<void(const modulus::log_record &)> 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<void(const modulus::log_record&)> sink_{};
std::function<void(const modulus::log_record &)> 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 <typename MESSAGE>
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 <typename MESSAGE>
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<std::int32_t>(level) >= static_cast<std::int32_t>(modulus::log_level::TRACE));
assert(static_cast<std::int32_t>(level) <= static_cast<std::int32_t>(modulus::log_level::OFF));
assert(location.file_name() != nullptr);
+3 -2
View File
@@ -7,10 +7,11 @@
#include <modulus/error.hpp>
namespace modulus {
template <typename VALUE>
auto success(VALUE&& value) -> std::expected<std::decay_t<VALUE>, modulus::error>;
auto success(VALUE &&value) -> std::expected<std::decay_t<VALUE>, modulus::error>;
template <typename VALUE>
auto fail(modulus::error value) -> std::expected<VALUE, modulus::error>;
@@ -18,7 +19,7 @@ auto fail(modulus::error value) -> std::expected<VALUE, modulus::error>;
}
template <typename VALUE>
auto modulus::success(VALUE&& value) -> std::expected<std::decay_t<VALUE>, modulus::error> {
auto modulus::success(VALUE &&value) -> std::expected<std::decay_t<VALUE>, modulus::error> {
return std::forward<VALUE>(value);
}
+12 -10
View File
@@ -5,31 +5,33 @@
#include <modulus/types.hpp>
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_{"<unknown>"};
std::char_t const *file_{"<unknown>"};
std::uint_least32_t line_{};
std::char_t const* function_{"<unknown>"};
std::char_t const *function_{"<unknown>"};
};
}
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_;
}
+4 -3
View File
@@ -3,10 +3,11 @@
#include <cstdint>
#if defined(__has_include)
# if __has_include(<stdfloat>)
# include <stdfloat>
# endif
#if __has_include(<stdfloat>)
#include <stdfloat>
#endif
#endif
namespace std {
-1
View File
@@ -1,2 +1 @@
__all__ = []
+7 -2
View File
@@ -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:
@@ -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:
+6 -6
View File
@@ -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])
+9 -1
View File
@@ -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", "."],
+301
View File
@@ -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"
+90
View File
@@ -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
+7
View File
@@ -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"
},
+14 -11
View File
@@ -17,6 +17,7 @@
#include <modulus/source_location.hpp>
#include <cstdint>
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<modulus::logging_config, modulus::error> {
auto modulus::parse_logging_config(boost::json::value const &value) -> std::expected<modulus::logging_config, modulus::error> {
auto object = modulus::json_config::require_object(value, "logging config");
if (!object) {
return modulus::fail<modulus::logging_config>(object.error());
@@ -99,7 +100,7 @@ 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<modulus::error_config, modulus::error> {
auto modulus::parse_error_config(boost::json::value const &value) -> std::expected<modulus::error_config, modulus::error> {
auto object = modulus::json_config::require_object(value, "error config");
if (!object) {
return modulus::fail<modulus::error_config>(object.error());
@@ -109,7 +110,8 @@ auto modulus::parse_error_config(boost::json::value const& value) -> std::expect
"include_code",
"include_message",
"include_location",
}, "error config");
},
"error config");
if (!unknown) {
return modulus::fail<modulus::error_config>(unknown.error());
}
@@ -137,7 +139,7 @@ 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<modulus::config, modulus::error> {
auto modulus::parse_config(boost::json::value const &value) -> std::expected<modulus::config, modulus::error> {
auto object = modulus::json_config::require_object(value, "config root");
if (!object) {
return modulus::fail<modulus::config>(object.error());
@@ -145,7 +147,8 @@ auto modulus::parse_config(boost::json::value const& value) -> std::expected<mod
auto unknown = modulus::json_config::reject_unknown_fields(**object, {
"logging",
"errors",
}, "config root");
},
"config root");
if (!unknown) {
return modulus::fail<modulus::config>(unknown.error());
}
@@ -167,7 +170,7 @@ auto modulus::parse_config(boost::json::value const& value) -> std::expected<mod
return modulus::success(config);
}
auto modulus::load_config(std::string const& path) -> std::expected<modulus::config, modulus::error> {
auto modulus::load_config(std::string const &path) -> std::expected<modulus::config, modulus::error> {
auto value = modulus::json_config::load_value(path, {
.category = "modulus.config",
.invalid_argument_code = 1,
@@ -179,7 +182,7 @@ auto modulus::load_config(std::string const& path) -> std::expected<modulus::con
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");
+7 -5
View File
@@ -6,21 +6,23 @@
#include <utility>
#include <cstdint>
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{};
+13 -12
View File
@@ -17,6 +17,7 @@
#include <modulus/error.hpp>
#include <modulus/types.hpp>
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<std::string_view> a
return false;
}
auto integer_value(boost::json::value const& value, std::string_view name, modulus::json_config::options config) -> std::expected<std::int64_t, modulus::error> {
auto integer_value(boost::json::value const &value, std::string_view name, modulus::json_config::options config) -> std::expected<std::int64_t, modulus::error> {
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<boost::json::value, modulus::error> {
auto modulus::json_config::load_value(std::string const &path, modulus::json_config::options config) -> std::expected<boost::json::value, modulus::error> {
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<boost::json::object const*, modulus::error> {
auto modulus::json_config::require_object(boost::json::value const &value, std::string_view context, modulus::json_config::options config) -> std::expected<boost::json::object const *, modulus::error> {
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<std::string_view> allowed, std::string_view context, modulus::json_config::options config) -> std::expected<void, modulus::error> {
auto modulus::json_config::reject_unknown_fields(boost::json::object const &object, std::initializer_list<std::string_view> allowed, std::string_view context, modulus::json_config::options config) -> std::expected<void, modulus::error> {
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<std::bool_t, modulus::error> {
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<std::bool_t, modulus::error> {
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<std::string, modulus::error> {
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<std::string, modulus::error> {
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<std::string, modulus::error> {
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<std::string, modulus::error> {
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<std::uint64_t, modulus::error> {
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<std::uint64_t, modulus::error> {
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<std::uint64_t, modulus::error> {
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<std::uint64_t, modulus::error> {
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<std::int32_t, modulus::error> {
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<std::int32_t, modulus::error> {
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<std::int32_t>(*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<std::chrono::milliseconds, modulus::error> {
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<std::chrono::milliseconds, modulus::error> {
auto parsed = modulus::json_config::positive_uint64_field(object, name, static_cast<std::uint64_t>(fallback.count()), max_value, config);
if (!parsed) {
return std::unexpected(parsed.error());
+22 -13
View File
@@ -7,6 +7,7 @@
#include <utility>
#include <modulus/types.hpp>
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<std::int32_t>(record_level) >= static_cast<std::int32_t>(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<std::bool_t>(sink_);
}
auto modulus::logger::set_sink(std::function<void(const modulus::log_record&)> sink) -> void {
auto modulus::logger::set_sink(std::function<void(const modulus::log_record &)> 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<void(const modulus::log_record&)>{};
auto sink = std::function<void(const modulus::log_record &)>{};
{
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;
}
+55
View File
@@ -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!")
+10 -9
View File
@@ -15,11 +15,12 @@
#include <modulus.hpp>
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<modulus::log_record>{};
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<modulus::log_record>{};
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<std::thread>{};
@@ -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<std::string>{};
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<modulus::log_record>{};
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()};
+46 -57
View File
@@ -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}}})
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"]),
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)):
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), \
(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(clangd.prepare(self.context), 0)
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), \
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(clangd.prepare(self.context), 1)
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)
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__":
-10
View File
@@ -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"]
def close(self):
if self.process.poll() is None:
self.process.terminate()
@@ -93,7 +86,6 @@ 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")
@@ -112,7 +104,6 @@ class container_clangd_test(unittest.TestCase):
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):
@@ -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())
+2 -51
View File
@@ -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):
"<hidden>",
],
)
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()
+8 -20
View File
@@ -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
+165
View File
@@ -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 <vector>\n#include <string>\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<class T>\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()
+5
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env python3
import modulus_tool
if __name__ == "__main__":
modulus_tool.run("format")
+67 -1
View File
@@ -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()