From 35f27d00b021f7ec8ab187164dc4c6df3b88af8d Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 16 Jun 2026 16:13:04 +0200 Subject: [PATCH] =?UTF-8?q?init:=20archlens=20=E2=80=94=20architecture=20d?= =?UTF-8?q?iagram=20generator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hex arch + DDD, tree-sitter parsing, Mermaid/ASCII output. Supports Rust + Python. 92 tests. CI, diff, --check for staleness detection. --- .gitea/workflows/ci.yml | 42 + .gitignore | 1 + CONTEXT.md | 55 + Cargo.lock | 1120 +++++++++++++++++ Cargo.toml | 59 + LICENSE | 21 + Makefile | 28 + README.md | 123 ++ crates/adapters/ascii/Cargo.toml | 10 + crates/adapters/ascii/src/ascii_renderer.rs | 153 +++ crates/adapters/ascii/src/lib.rs | 3 + .../ascii/tests/ascii_renderer_tests.rs | 48 + crates/adapters/cargo-workspace/Cargo.toml | 15 + .../src/cargo_workspace_analyzer.rs | 123 ++ crates/adapters/cargo-workspace/src/lib.rs | 3 + .../tests/cargo_workspace_tests.rs | 132 ++ crates/adapters/file-writer/Cargo.toml | 13 + .../file-writer/src/file_output_writer.rs | 52 + crates/adapters/file-writer/src/lib.rs | 3 + .../file-writer/tests/file_writer_tests.rs | 35 + crates/adapters/mermaid/Cargo.toml | 10 + crates/adapters/mermaid/src/lib.rs | 3 + .../adapters/mermaid/src/mermaid_renderer.rs | 266 ++++ .../mermaid/tests/mermaid_renderer_tests.rs | 328 +++++ crates/adapters/stdout-writer/Cargo.toml | 10 + crates/adapters/stdout-writer/src/lib.rs | 3 + .../stdout-writer/src/stdout_output_writer.rs | 24 + .../tests/stdout_writer_tests.rs | 12 + crates/adapters/toml-config/Cargo.toml | 15 + crates/adapters/toml-config/src/lib.rs | 3 + .../toml-config/src/toml_config_loader.rs | 81 ++ .../toml-config/tests/toml_config_tests.rs | 68 + crates/adapters/tree-sitter/Cargo.toml | 17 + crates/adapters/tree-sitter/src/lib.rs | 5 + crates/adapters/tree-sitter/src/python/mod.rs | 337 +++++ crates/adapters/tree-sitter/src/rust/mod.rs | 349 +++++ .../tree-sitter/src/tree_sitter_analyzer.rs | 30 + .../tests/python_analyzer_tests.rs | 138 ++ .../tree-sitter/tests/rust_analyzer_tests.rs | 130 ++ crates/adapters/walkdir/Cargo.toml | 15 + crates/adapters/walkdir/src/lib.rs | 3 + .../adapters/walkdir/src/walkdir_discovery.rs | 100 ++ .../walkdir/tests/walkdir_discovery_tests.rs | 71 ++ crates/application/Cargo.toml | 11 + crates/application/src/lib.rs | 1 + .../src/queries/analyze_codebase.rs | 244 ++++ crates/application/src/queries/mod.rs | 5 + .../src/queries/render_diagrams.rs | 45 + .../tests/analyze_codebase_tests.rs | 344 +++++ .../tests/fakes/diagram_renderer.rs | 17 + .../application/tests/fakes/file_discovery.rs | 27 + crates/application/tests/fakes/mod.rs | 9 + .../application/tests/fakes/output_writer.rs | 26 + .../tests/fakes/source_analyzer.rs | 43 + .../tests/render_diagrams_tests.rs | 78 ++ crates/domain/Cargo.toml | 8 + crates/domain/src/aggregates/code_graph.rs | 78 ++ crates/domain/src/aggregates/mod.rs | 3 + crates/domain/src/entities/code_element.rs | 111 ++ crates/domain/src/entities/mod.rs | 5 + crates/domain/src/entities/relationship.rs | 49 + crates/domain/src/error.rs | 14 + crates/domain/src/lib.rs | 14 + crates/domain/src/ports/config_loader.rs | 6 + crates/domain/src/ports/diagram_renderer.rs | 5 + crates/domain/src/ports/file_discovery.rs | 9 + crates/domain/src/ports/mod.rs | 13 + crates/domain/src/ports/output_writer.rs | 5 + crates/domain/src/ports/project_analyzer.rs | 7 + crates/domain/src/ports/source_analyzer.rs | 5 + .../value_objects/analysis/analysis_config.rs | 60 + .../value_objects/analysis/analysis_result.rs | 42 + .../analysis/analysis_warning.rs | 34 + .../domain/src/value_objects/analysis/mod.rs | 7 + .../value_objects/graph/code_element_kind.rs | 9 + crates/domain/src/value_objects/graph/mod.rs | 7 + .../value_objects/graph/relationship_kind.rs | 6 + .../src/value_objects/graph/visibility.rs | 6 + crates/domain/src/value_objects/mod.rs | 4 + .../src/value_objects/output/diagram_level.rs | 6 + crates/domain/src/value_objects/output/mod.rs | 9 + .../src/value_objects/output/output_config.rs | 25 + .../src/value_objects/output/render_output.rs | 20 + .../src/value_objects/output/rendered_file.rs | 32 + .../src/value_objects/source/file_path.rs | 18 + .../src/value_objects/source/language.rs | 16 + crates/domain/src/value_objects/source/mod.rs | 9 + .../src/value_objects/source/module_name.rs | 18 + .../src/value_objects/source/source_file.rs | 21 + crates/domain/tests/analysis_result_tests.rs | 39 + crates/domain/tests/analysis_warning_tests.rs | 21 + crates/domain/tests/code_element_tests.rs | 107 ++ crates/domain/tests/code_graph_tests.rs | 130 ++ crates/domain/tests/config_tests.rs | 37 + crates/domain/tests/file_path_tests.rs | 29 + crates/domain/tests/language_tests.rs | 18 + crates/domain/tests/module_name_tests.rs | 23 + crates/domain/tests/render_output_tests.rs | 37 + crates/domain/tests/source_file_tests.rs | 10 + crates/presentation/Cargo.toml | 28 + crates/presentation/src/cli.rs | 61 + crates/presentation/src/lib.rs | 392 ++++++ crates/presentation/src/main.rs | 9 + crates/presentation/tests/end_to_end_tests.rs | 133 ++ .../0001-hexagonal-architecture-with-ddd.md | 51 + docs/adr/0002-tree-sitter-for-parsing.md | 31 + 106 files changed, 6744 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CONTEXT.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 crates/adapters/ascii/Cargo.toml create mode 100644 crates/adapters/ascii/src/ascii_renderer.rs create mode 100644 crates/adapters/ascii/src/lib.rs create mode 100644 crates/adapters/ascii/tests/ascii_renderer_tests.rs create mode 100644 crates/adapters/cargo-workspace/Cargo.toml create mode 100644 crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs create mode 100644 crates/adapters/cargo-workspace/src/lib.rs create mode 100644 crates/adapters/cargo-workspace/tests/cargo_workspace_tests.rs create mode 100644 crates/adapters/file-writer/Cargo.toml create mode 100644 crates/adapters/file-writer/src/file_output_writer.rs create mode 100644 crates/adapters/file-writer/src/lib.rs create mode 100644 crates/adapters/file-writer/tests/file_writer_tests.rs create mode 100644 crates/adapters/mermaid/Cargo.toml create mode 100644 crates/adapters/mermaid/src/lib.rs create mode 100644 crates/adapters/mermaid/src/mermaid_renderer.rs create mode 100644 crates/adapters/mermaid/tests/mermaid_renderer_tests.rs create mode 100644 crates/adapters/stdout-writer/Cargo.toml create mode 100644 crates/adapters/stdout-writer/src/lib.rs create mode 100644 crates/adapters/stdout-writer/src/stdout_output_writer.rs create mode 100644 crates/adapters/stdout-writer/tests/stdout_writer_tests.rs create mode 100644 crates/adapters/toml-config/Cargo.toml create mode 100644 crates/adapters/toml-config/src/lib.rs create mode 100644 crates/adapters/toml-config/src/toml_config_loader.rs create mode 100644 crates/adapters/toml-config/tests/toml_config_tests.rs create mode 100644 crates/adapters/tree-sitter/Cargo.toml create mode 100644 crates/adapters/tree-sitter/src/lib.rs create mode 100644 crates/adapters/tree-sitter/src/python/mod.rs create mode 100644 crates/adapters/tree-sitter/src/rust/mod.rs create mode 100644 crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs create mode 100644 crates/adapters/tree-sitter/tests/python_analyzer_tests.rs create mode 100644 crates/adapters/tree-sitter/tests/rust_analyzer_tests.rs create mode 100644 crates/adapters/walkdir/Cargo.toml create mode 100644 crates/adapters/walkdir/src/lib.rs create mode 100644 crates/adapters/walkdir/src/walkdir_discovery.rs create mode 100644 crates/adapters/walkdir/tests/walkdir_discovery_tests.rs create mode 100644 crates/application/Cargo.toml create mode 100644 crates/application/src/lib.rs create mode 100644 crates/application/src/queries/analyze_codebase.rs create mode 100644 crates/application/src/queries/mod.rs create mode 100644 crates/application/src/queries/render_diagrams.rs create mode 100644 crates/application/tests/analyze_codebase_tests.rs create mode 100644 crates/application/tests/fakes/diagram_renderer.rs create mode 100644 crates/application/tests/fakes/file_discovery.rs create mode 100644 crates/application/tests/fakes/mod.rs create mode 100644 crates/application/tests/fakes/output_writer.rs create mode 100644 crates/application/tests/fakes/source_analyzer.rs create mode 100644 crates/application/tests/render_diagrams_tests.rs create mode 100644 crates/domain/Cargo.toml create mode 100644 crates/domain/src/aggregates/code_graph.rs create mode 100644 crates/domain/src/aggregates/mod.rs create mode 100644 crates/domain/src/entities/code_element.rs create mode 100644 crates/domain/src/entities/mod.rs create mode 100644 crates/domain/src/entities/relationship.rs create mode 100644 crates/domain/src/error.rs create mode 100644 crates/domain/src/lib.rs create mode 100644 crates/domain/src/ports/config_loader.rs create mode 100644 crates/domain/src/ports/diagram_renderer.rs create mode 100644 crates/domain/src/ports/file_discovery.rs create mode 100644 crates/domain/src/ports/mod.rs create mode 100644 crates/domain/src/ports/output_writer.rs create mode 100644 crates/domain/src/ports/project_analyzer.rs create mode 100644 crates/domain/src/ports/source_analyzer.rs create mode 100644 crates/domain/src/value_objects/analysis/analysis_config.rs create mode 100644 crates/domain/src/value_objects/analysis/analysis_result.rs create mode 100644 crates/domain/src/value_objects/analysis/analysis_warning.rs create mode 100644 crates/domain/src/value_objects/analysis/mod.rs create mode 100644 crates/domain/src/value_objects/graph/code_element_kind.rs create mode 100644 crates/domain/src/value_objects/graph/mod.rs create mode 100644 crates/domain/src/value_objects/graph/relationship_kind.rs create mode 100644 crates/domain/src/value_objects/graph/visibility.rs create mode 100644 crates/domain/src/value_objects/mod.rs create mode 100644 crates/domain/src/value_objects/output/diagram_level.rs create mode 100644 crates/domain/src/value_objects/output/mod.rs create mode 100644 crates/domain/src/value_objects/output/output_config.rs create mode 100644 crates/domain/src/value_objects/output/render_output.rs create mode 100644 crates/domain/src/value_objects/output/rendered_file.rs create mode 100644 crates/domain/src/value_objects/source/file_path.rs create mode 100644 crates/domain/src/value_objects/source/language.rs create mode 100644 crates/domain/src/value_objects/source/mod.rs create mode 100644 crates/domain/src/value_objects/source/module_name.rs create mode 100644 crates/domain/src/value_objects/source/source_file.rs create mode 100644 crates/domain/tests/analysis_result_tests.rs create mode 100644 crates/domain/tests/analysis_warning_tests.rs create mode 100644 crates/domain/tests/code_element_tests.rs create mode 100644 crates/domain/tests/code_graph_tests.rs create mode 100644 crates/domain/tests/config_tests.rs create mode 100644 crates/domain/tests/file_path_tests.rs create mode 100644 crates/domain/tests/language_tests.rs create mode 100644 crates/domain/tests/module_name_tests.rs create mode 100644 crates/domain/tests/render_output_tests.rs create mode 100644 crates/domain/tests/source_file_tests.rs create mode 100644 crates/presentation/Cargo.toml create mode 100644 crates/presentation/src/cli.rs create mode 100644 crates/presentation/src/lib.rs create mode 100644 crates/presentation/src/main.rs create mode 100644 crates/presentation/tests/end_to_end_tests.rs create mode 100644 docs/adr/0001-hexagonal-architecture-with-ddd.md create mode 100644 docs/adr/0002-tree-sitter-for-parsing.md diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..4c8329f --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + check: + name: Check / Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: fmt + run: cargo fmt --all -- --check + + - name: clippy + run: cargo clippy --all-targets -- -D warnings + + - name: test + run: cargo test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..58d46e5 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,55 @@ +# Archlens — Domain Glossary + +> This file defines the canonical language of the Archlens domain. No implementation details — just terms and their meanings. + +## Core Concepts + +- **CodeGraph**: The aggregate root. A complete representation of a codebase's architecture — all elements, relationships, and module groupings. Can produce filtered subgraphs of itself by module. + +- **CodeElement**: A type-level construct in source code — a class, struct, trait, interface, or enum. Carries a name, kind, location (file + line), visibility, generic parameters, and attributes/decorators. + +- **Relationship**: A directed edge between two CodeElements. Has a kind (inheritance or composition) and references source and target elements. + +- **Module**: A logical grouping of CodeElements. By default inferred from directory structure, overridable via configuration. Represented as metadata on each CodeElement (`module_path`), not a separate hierarchy. + +- **Language**: The programming language of a source file. A closed set: Rust, CSharp, Python. Determines which analysis strategy is used. + +- **SourceFile**: A file to be analyzed. Carries a FilePath and its detected Language. + +## Analysis + +- **AnalysisResult**: The output of analyzing a single file — extracted CodeElements, Relationships, and any AnalysisWarnings. + +- **AnalysisWarning**: A non-fatal problem encountered during analysis — unparseable construct, unsupported syntax, etc. Carries file path, line, and message. + +- **AnalysisConfig**: Settings that control what gets analyzed — path excludes, granularity level, module-to-directory mappings. + +## Output + +- **RenderOutput**: The result of rendering a CodeGraph — a collection of RenderedFiles. + +- **RenderedFile**: A single output artifact — a named file with content (e.g., `overview.mmd` with Mermaid syntax). + +- **OutputConfig**: Settings that control rendering — format, output path, whether to split by module. + +## Relationships (kinds) + +- **Inheritance**: A type extends or implements another type (class extends class, struct implements trait, class implements interface). + +- **Composition**: A type owns or depends on another type via a field, property, or constructor-injected dependency. + +## Metadata on CodeElements + +- **Visibility**: Whether a type is public, private, internal, or other access level. Used as a filterable property, not shown on diagrams by default. + +- **Generics**: Type parameters on a CodeElement. Stored with the element, displayed in simplified form (e.g., `Repository` not full generic constraints). + +- **Attributes**: Decorators, annotations, or attributes on a type (e.g., `[ApiController]`, `#[derive(Clone)]`, `@dataclass`). Stored as metadata, usable for categorization and filtering. + +## Diagram Levels + +- **Project level**: Dependencies between projects, crates, or packages. Rendered as a flowchart. + +- **Module level**: Dependencies between logical modules (directory-based groupings). Rendered as a flowchart with subgraphs. + +- **Type level**: Individual types with their inheritance and composition relationships. Rendered as a UML class diagram. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..dd8711a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1120 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "archlens" +version = "0.1.0" +dependencies = [ + "anyhow", + "archlens-application", + "archlens-ascii", + "archlens-cargo-workspace", + "archlens-domain", + "archlens-file-writer", + "archlens-mermaid", + "archlens-stdout-writer", + "archlens-toml-config", + "archlens-tree-sitter", + "archlens-walkdir", + "clap", + "tempfile", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "archlens-application" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "rayon", + "thiserror", + "tracing", +] + +[[package]] +name = "archlens-ascii" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "thiserror", + "tracing", +] + +[[package]] +name = "archlens-cargo-workspace" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "serde", + "tempfile", + "thiserror", + "toml", + "tracing", +] + +[[package]] +name = "archlens-domain" +version = "0.1.0" +dependencies = [ + "thiserror", +] + +[[package]] +name = "archlens-file-writer" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "tempfile", + "thiserror", + "tracing", +] + +[[package]] +name = "archlens-mermaid" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "thiserror", + "tracing", +] + +[[package]] +name = "archlens-stdout-writer" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "thiserror", + "tracing", +] + +[[package]] +name = "archlens-toml-config" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "serde", + "tempfile", + "thiserror", + "toml", + "tracing", +] + +[[package]] +name = "archlens-tree-sitter" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "tempfile", + "thiserror", + "tracing", + "tree-sitter", + "tree-sitter-c-sharp", + "tree-sitter-python", + "tree-sitter-rust", +] + +[[package]] +name = "archlens-walkdir" +version = "0.1.0" +dependencies = [ + "archlens-domain", + "ignore", + "tempfile", + "thiserror", + "tracing", + "walkdir", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ignore" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tree-sitter" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ecad204 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,59 @@ +[workspace] +resolver = "2" +members = [ + "crates/domain", + "crates/application", + "crates/presentation", + "crates/adapters/tree-sitter", + "crates/adapters/walkdir", + "crates/adapters/mermaid", + "crates/adapters/ascii", + "crates/adapters/file-writer", + "crates/adapters/stdout-writer", + "crates/adapters/toml-config", + "crates/adapters/cargo-workspace", +] + +[workspace.dependencies] +# Internal crates +archlens-domain = { path = "crates/domain" } +archlens-application = { path = "crates/application" } +archlens-tree-sitter = { path = "crates/adapters/tree-sitter" } +archlens-walkdir = { path = "crates/adapters/walkdir" } +archlens-mermaid = { path = "crates/adapters/mermaid" } +archlens-ascii = { path = "crates/adapters/ascii" } +archlens-file-writer = { path = "crates/adapters/file-writer" } +archlens-stdout-writer = { path = "crates/adapters/stdout-writer" } +archlens-toml-config = { path = "crates/adapters/toml-config" } +archlens-cargo-workspace = { path = "crates/adapters/cargo-workspace" } + +# Error handling +thiserror = "2" +anyhow = "1" + +# Parsing +tree-sitter = "0.24" +tree-sitter-rust = "0.23" +tree-sitter-python = "0.23" +tree-sitter-c-sharp = "0.23" + +# File discovery +walkdir = "2" +ignore = "0.4" + +# Config +toml = "0.8" +serde = { version = "1", features = ["derive"] } + +# CLI +clap = { version = "4", features = ["derive"] } + +# Observability +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Parallelism +rayon = "1" + +# Testing +tempfile = "3" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..31bafd2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Gabriel Kaszewski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e2f26a7 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.DEFAULT_GOAL := check + +# Run the full local check suite — same order as CI would. +check: fmt-check clippy test + @echo "✅ All checks passed" + +# Apply rustfmt to all files. +fmt: + cargo fmt + +# Check formatting without modifying files (CI-safe). +fmt-check: + cargo fmt --check + +# Run Clippy and treat warnings as errors. +clippy: + cargo clippy -- -D warnings + +# Run the test suite. +test: + cargo test + +# Apply fmt + clippy auto-fixes in one shot. +fix: + cargo fmt + cargo clippy --fix --allow-dirty --allow-staged + +.PHONY: check fmt fmt-check clippy test fix diff --git a/README.md b/README.md new file mode 100644 index 0000000..0aaa377 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# archlens + +Generate architecture diagrams from source code. Runs on CI to keep docs fresh. + +Supports Rust and Python. Produces Mermaid or ASCII output. + +## Install + +```bash +cargo install --path crates/presentation +``` + +## Usage + +```bash +# Module-level dependency graph (default) +archlens . + +# Project-level (crate/package dependencies from manifests) +archlens . --level project + +# Type-level class diagram +archlens . --level type + +# Write to file +archlens . --output docs/architecture.mmd + +# Split by module (one file per module + overview) +archlens . --level type --split-by-module --output docs/arch/ + +# ASCII output to terminal +archlens . --format ascii + +# Scope to a subtree +archlens . --scope src/domain + +# Exclude directories +archlens . --exclude tests/ --exclude generated/ + +# Verbose logging +archlens . -v # info +archlens . -vv # debug +``` + +## CI Integration + +Check if committed diagrams are up to date: + +```bash +archlens . --level project --check --output docs/architecture.mmd +``` + +Exit code 1 if the diagram has changed. Use `--strict` to also fail on parse warnings. + +Compare current state against an existing file: + +```bash +archlens diff docs/architecture.mmd --level project +``` + +## Config + +Generate a config file: + +```bash +archlens init +``` + +Creates `archlens.toml`: + +```toml +[analysis] +exclude = ["tests/", "vendor/", "generated/"] +level = "module" + +[modules] +# "src/infra" = "Infrastructure" + +[output] +format = "mermaid" +# path = "docs/architecture.mmd" +split_by_module = false +``` + +## Diagram Levels + +| Level | What it shows | Source | +|-------|--------------|--------| +| `project` | Crate/package dependencies | `Cargo.toml` | +| `module` | Module-level dependency graph | Imports + manifest deps | +| `type` | Class diagram with fields, methods, relationships | Source code (tree-sitter) | + +## Supported Languages + +| Language | Types | Inheritance | Composition | Imports | +|----------|-------|-------------|-------------|---------| +| Rust | struct, enum, trait | `impl Trait for Type` | struct fields | `use`, `mod` | +| Python | class | `class Foo(Bar)` | `__init__` params, type annotations | `import`, `from ... import` | +| C# | planned | - | - | - | + +## Architecture + +Built with hexagonal architecture (ports and adapters) + DDD. + +``` +crates/ + domain/ # Core model, zero external deps + application/ # Use cases, orchestration + adapters/ + tree-sitter/ # Source code parsing (Rust, Python) + cargo-workspace/ # Cargo.toml dependency extraction + walkdir/ # File discovery + mermaid/ # Mermaid diagram output + ascii/ # Terminal output + file-writer/ # Write to disk + stdout-writer/ # Write to stdout + toml-config/ # Config file parsing + presentation/ # CLI (clap), composition root +``` + +## License + +MIT diff --git a/crates/adapters/ascii/Cargo.toml b/crates/adapters/ascii/Cargo.toml new file mode 100644 index 0000000..8308808 --- /dev/null +++ b/crates/adapters/ascii/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "archlens-ascii" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true diff --git a/crates/adapters/ascii/src/ascii_renderer.rs b/crates/adapters/ascii/src/ascii_renderer.rs new file mode 100644 index 0000000..d37a884 --- /dev/null +++ b/crates/adapters/ascii/src/ascii_renderer.rs @@ -0,0 +1,153 @@ +use std::collections::HashMap; + +use archlens_domain::{ + CodeElement, CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile, + ports::DiagramRenderer, +}; + +pub struct AsciiRenderer; + +impl Default for AsciiRenderer { + fn default() -> Self { + Self::new() + } +} + +impl AsciiRenderer { + pub fn new() -> Self { + Self + } + + fn format_kind(element: &CodeElement) -> &'static str { + match element.kind() { + archlens_domain::CodeElementKind::Class => "cls", + archlens_domain::CodeElementKind::Struct => "str", + archlens_domain::CodeElementKind::Trait => "trt", + archlens_domain::CodeElementKind::Interface => "ifc", + archlens_domain::CodeElementKind::Enum => "enm", + archlens_domain::CodeElementKind::Project => "prj", + } + } +} + +impl DiagramRenderer for AsciiRenderer { + fn render(&self, graph: &CodeGraph) -> Result { + let mut lines = Vec::new(); + + let total_elements = graph.elements().len(); + let total_rels = graph.relationships().len(); + let total_modules = graph.modules().len(); + + lines.push("╔══════════════════════════════════════╗".to_string()); + lines.push("║ Architecture Overview ║".to_string()); + lines.push("╠══════════════════════════════════════╣".to_string()); + lines.push(format!( + "║ Elements: {:<5} Modules: {:<5} ║", + total_elements, total_modules + )); + lines.push(format!("║ Relationships: {:<19} ║", total_rels)); + lines.push("╚══════════════════════════════════════╝".to_string()); + + if graph.elements().is_empty() { + lines.push(" (no elements found)".to_string()); + let content = lines.join("\n"); + let file = RenderedFile::new("diagram.txt", &content)?; + return Ok(RenderOutput::single(file)); + } + + let mut grouped: HashMap> = HashMap::new(); + let mut ungrouped: Vec<&CodeElement> = Vec::new(); + + for element in graph.elements() { + if let Some(module) = element.module() { + grouped + .entry(module.as_str().to_string()) + .or_default() + .push(element); + } else { + ungrouped.push(element); + } + } + + if !ungrouped.is_empty() { + lines.push(String::new()); + lines.push("┌─ (ungrouped)".to_string()); + for el in &ungrouped { + lines.push(format!("│ [{}] {}", Self::format_kind(el), el.name())); + } + lines.push("└───".to_string()); + } + + let mut module_names: Vec<&String> = grouped.keys().collect(); + module_names.sort(); + + for module in module_names { + let elements = &grouped[module]; + lines.push(String::new()); + lines.push(format!("┌─ {} ({} types)", module, elements.len())); + lines.push("│".to_string()); + for (i, el) in elements.iter().enumerate() { + let prefix = if i == elements.len() - 1 { + "└──" + } else { + "├──" + }; + let generics = if el.generics().is_empty() { + String::new() + } else { + format!("<{}>", el.generics().join(", ")) + }; + lines.push(format!( + "│ {} [{}] {}{}", + prefix, + Self::format_kind(el), + el.name(), + generics + )); + } + lines.push("└───".to_string()); + } + + let non_import_rels: Vec<_> = graph + .relationships() + .iter() + .filter(|r| r.kind() != RelationshipKind::Import) + .collect(); + + if !non_import_rels.is_empty() { + lines.push(String::new()); + lines.push("── Relationships ──".to_string()); + for rel in &non_import_rels { + let arrow = match rel.kind() { + RelationshipKind::Inheritance => "extends", + RelationshipKind::Composition => "has", + RelationshipKind::Import => "imports", + }; + lines.push(format!( + " {} ─[{}]─> {}", + rel.source(), + arrow, + rel.target() + )); + } + } + + let import_rels: Vec<_> = graph + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Import) + .collect(); + + if !import_rels.is_empty() { + lines.push(String::new()); + lines.push(format!("── Imports ({}) ──", import_rels.len())); + for rel in &import_rels { + lines.push(format!(" {} ···> {}", rel.source(), rel.target())); + } + } + + let content = lines.join("\n"); + let file = RenderedFile::new("diagram.txt", &content)?; + Ok(RenderOutput::single(file)) + } +} diff --git a/crates/adapters/ascii/src/lib.rs b/crates/adapters/ascii/src/lib.rs new file mode 100644 index 0000000..96dfcf6 --- /dev/null +++ b/crates/adapters/ascii/src/lib.rs @@ -0,0 +1,3 @@ +mod ascii_renderer; + +pub use ascii_renderer::AsciiRenderer; diff --git a/crates/adapters/ascii/tests/ascii_renderer_tests.rs b/crates/adapters/ascii/tests/ascii_renderer_tests.rs new file mode 100644 index 0000000..13d54ed --- /dev/null +++ b/crates/adapters/ascii/tests/ascii_renderer_tests.rs @@ -0,0 +1,48 @@ +use archlens_ascii::AsciiRenderer; +use archlens_domain::{ + CodeElement, CodeElementKind, CodeGraph, FilePath, Relationship, RelationshipKind, + ports::DiagramRenderer, +}; + +#[test] +fn renders_elements_and_relationships_as_text() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/service.rs").unwrap(), + 1, + ) + .unwrap(), + ); + graph.add_element( + CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/order.rs").unwrap(), + 1, + ) + .unwrap(), + ); + graph.add_relationship( + Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(), + ); + + let renderer = AsciiRenderer::new(); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("OrderService")); + assert!(content.contains("Order")); + assert!(content.contains("has")); +} + +#[test] +fn empty_graph_produces_header_only() { + let renderer = AsciiRenderer::new(); + let output = renderer.render(&CodeGraph::new()).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("Architecture")); +} diff --git a/crates/adapters/cargo-workspace/Cargo.toml b/crates/adapters/cargo-workspace/Cargo.toml new file mode 100644 index 0000000..3aa8934 --- /dev/null +++ b/crates/adapters/cargo-workspace/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "archlens-cargo-workspace" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true +toml.workspace = true +serde.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs b/crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs new file mode 100644 index 0000000..2647668 --- /dev/null +++ b/crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs @@ -0,0 +1,123 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use serde::Deserialize; + +use archlens_domain::{ + CodeElement, CodeElementKind, CodeGraph, DomainError, FilePath, ModuleName, Relationship, + RelationshipKind, ports::ProjectAnalyzer, +}; + +pub struct CargoWorkspaceAnalyzer; + +impl Default for CargoWorkspaceAnalyzer { + fn default() -> Self { + Self::new() + } +} + +impl CargoWorkspaceAnalyzer { + pub fn new() -> Self { + Self + } +} + +#[derive(Deserialize)] +struct WorkspaceToml { + workspace: Option, +} + +#[derive(Deserialize)] +struct WorkspaceSection { + #[serde(default)] + members: Vec, +} + +#[derive(Deserialize)] +struct MemberToml { + package: Option, + #[serde(default)] + dependencies: HashMap, +} + +#[derive(Deserialize)] +struct PackageSection { + name: String, +} + +impl ProjectAnalyzer for CargoWorkspaceAnalyzer { + fn analyze(&self, root: &Path) -> Result { + let workspace_toml_path = root.join("Cargo.toml"); + let content = std::fs::read_to_string(&workspace_toml_path) + .map_err(|e| DomainError::IoError(e.to_string()))?; + let workspace: WorkspaceToml = + toml::from_str(&content).map_err(|e| DomainError::ConfigError(e.to_string()))?; + + let members = workspace.workspace.map(|w| w.members).unwrap_or_default(); + + let mut graph = CodeGraph::new(); + let mut name_set: HashSet = HashSet::new(); + let mut member_names: Vec<(String, String)> = Vec::new(); // (member_path, package_name) + + for member_path in &members { + let member_cargo = root.join(member_path).join("Cargo.toml"); + let member_content = std::fs::read_to_string(&member_cargo) + .map_err(|e| DomainError::IoError(e.to_string()))?; + let member: MemberToml = toml::from_str(&member_content) + .map_err(|e| DomainError::ConfigError(e.to_string()))?; + + let package_name = member + .package + .map(|p| p.name) + .unwrap_or_else(|| member_path.clone()); + + name_set.insert(package_name.clone()); + member_names.push((member_path.clone(), package_name)); + } + + for (member_path, package_name) in &member_names { + let file_path = FilePath::new(&format!("{}/Cargo.toml", member_path)) + .map_err(|e| DomainError::IoError(e.to_string()))?; + + let mut element = + CodeElement::new(package_name, CodeElementKind::Project, file_path, 1)?; + + if let Some(module) = infer_group(member_path) { + element = element.with_module(module); + } + + graph.add_element(element); + } + + for (member_path, package_name) in &member_names { + let member_cargo = root.join(member_path).join("Cargo.toml"); + let member_content = std::fs::read_to_string(&member_cargo) + .map_err(|e| DomainError::IoError(e.to_string()))?; + let member: MemberToml = toml::from_str(&member_content) + .map_err(|e| DomainError::ConfigError(e.to_string()))?; + + for dep_name in member.dependencies.keys() { + let normalized = dep_name.replace('_', "-"); + if name_set.contains(&normalized) + && let Ok(rel) = + Relationship::new(package_name, &normalized, RelationshipKind::Composition) + { + graph.add_relationship(rel); + } + } + } + + Ok(graph) + } +} + +fn infer_group(member_path: &str) -> Option { + let parts: Vec<&str> = member_path.split('/').collect(); + if parts.len() < 3 { + return None; + } + + let group = parts[parts.len() - 2]; + let capitalized = format!("{}{}", group[..1].to_uppercase(), &group[1..]); + ModuleName::new(&capitalized).ok() +} diff --git a/crates/adapters/cargo-workspace/src/lib.rs b/crates/adapters/cargo-workspace/src/lib.rs new file mode 100644 index 0000000..e91beb1 --- /dev/null +++ b/crates/adapters/cargo-workspace/src/lib.rs @@ -0,0 +1,3 @@ +mod cargo_workspace_analyzer; + +pub use cargo_workspace_analyzer::CargoWorkspaceAnalyzer; diff --git a/crates/adapters/cargo-workspace/tests/cargo_workspace_tests.rs b/crates/adapters/cargo-workspace/tests/cargo_workspace_tests.rs new file mode 100644 index 0000000..43661da --- /dev/null +++ b/crates/adapters/cargo-workspace/tests/cargo_workspace_tests.rs @@ -0,0 +1,132 @@ +use std::fs; + +use archlens_cargo_workspace::CargoWorkspaceAnalyzer; +use archlens_domain::{CodeElementKind, RelationshipKind, ports::ProjectAnalyzer}; + +fn create_workspace(dir: &std::path::Path) { + fs::write( + dir.join("Cargo.toml"), + r#" +[workspace] +members = ["crates/domain", "crates/application", "crates/adapters/sqlite"] +"#, + ) + .unwrap(); + + fs::create_dir_all(dir.join("crates/domain/src")).unwrap(); + fs::write( + dir.join("crates/domain/Cargo.toml"), + r#" +[package] +name = "myapp-domain" +version = "0.1.0" +edition = "2024" + +[dependencies] +"#, + ) + .unwrap(); + fs::write(dir.join("crates/domain/src/lib.rs"), "").unwrap(); + + fs::create_dir_all(dir.join("crates/application/src")).unwrap(); + fs::write( + dir.join("crates/application/Cargo.toml"), + r#" +[package] +name = "myapp-application" +version = "0.1.0" +edition = "2024" + +[dependencies] +myapp-domain = { path = "../domain" } +"#, + ) + .unwrap(); + fs::write(dir.join("crates/application/src/lib.rs"), "").unwrap(); + + fs::create_dir_all(dir.join("crates/adapters/sqlite/src")).unwrap(); + fs::write( + dir.join("crates/adapters/sqlite/Cargo.toml"), + r#" +[package] +name = "myapp-sqlite" +version = "0.1.0" +edition = "2024" + +[dependencies] +myapp-domain = { path = "../../domain" } +"#, + ) + .unwrap(); + fs::write(dir.join("crates/adapters/sqlite/src/lib.rs"), "").unwrap(); +} + +#[test] +fn discovers_workspace_members_as_project_elements() { + let dir = tempfile::tempdir().unwrap(); + create_workspace(dir.path()); + + let analyzer = CargoWorkspaceAnalyzer::new(); + let graph = analyzer.analyze(dir.path()).unwrap(); + + assert_eq!(graph.elements().len(), 3); + assert!( + graph + .elements() + .iter() + .all(|e| e.kind() == CodeElementKind::Project) + ); + + let names: Vec<&str> = graph.elements().iter().map(|e| e.name()).collect(); + assert!(names.contains(&"myapp-domain")); + assert!(names.contains(&"myapp-application")); + assert!(names.contains(&"myapp-sqlite")); +} + +#[test] +fn extracts_dependencies_between_workspace_members() { + let dir = tempfile::tempdir().unwrap(); + create_workspace(dir.path()); + + let analyzer = CargoWorkspaceAnalyzer::new(); + let graph = analyzer.analyze(dir.path()).unwrap(); + + assert_eq!(graph.relationships().len(), 2); + assert!( + graph + .relationships() + .iter() + .all(|r| r.kind() == RelationshipKind::Composition) + ); + + let deps: Vec<(&str, &str)> = graph + .relationships() + .iter() + .map(|r| (r.source(), r.target())) + .collect(); + assert!(deps.contains(&("myapp-application", "myapp-domain"))); + assert!(deps.contains(&("myapp-sqlite", "myapp-domain"))); +} + +#[test] +fn assigns_module_from_directory_grouping() { + let dir = tempfile::tempdir().unwrap(); + create_workspace(dir.path()); + + let analyzer = CargoWorkspaceAnalyzer::new(); + let graph = analyzer.analyze(dir.path()).unwrap(); + + let sqlite = graph + .elements() + .iter() + .find(|e| e.name() == "myapp-sqlite") + .unwrap(); + assert_eq!(sqlite.module().unwrap().as_str(), "Adapters"); + + let domain = graph + .elements() + .iter() + .find(|e| e.name() == "myapp-domain") + .unwrap(); + assert!(domain.module().is_none() || domain.module().unwrap().as_str() != "Adapters"); +} diff --git a/crates/adapters/file-writer/Cargo.toml b/crates/adapters/file-writer/Cargo.toml new file mode 100644 index 0000000..ae6d65a --- /dev/null +++ b/crates/adapters/file-writer/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "archlens-file-writer" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/adapters/file-writer/src/file_output_writer.rs b/crates/adapters/file-writer/src/file_output_writer.rs new file mode 100644 index 0000000..0798c7d --- /dev/null +++ b/crates/adapters/file-writer/src/file_output_writer.rs @@ -0,0 +1,52 @@ +use std::fs; +use std::path::PathBuf; + +use archlens_domain::{DomainError, RenderOutput, ports::OutputWriter}; + +pub struct FileOutputWriter { + output_path: OutputPath, +} + +enum OutputPath { + Directory(PathBuf), + File(PathBuf), +} + +impl FileOutputWriter { + pub fn new(output_dir: PathBuf) -> Self { + Self { + output_path: OutputPath::Directory(output_dir), + } + } + + pub fn single_file(path: PathBuf) -> Self { + Self { + output_path: OutputPath::File(path), + } + } +} + +impl OutputWriter for FileOutputWriter { + fn write(&self, output: &RenderOutput) -> Result<(), DomainError> { + match &self.output_path { + OutputPath::File(path) => { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| DomainError::IoError(e.to_string()))?; + } + let content = output.files().first().map(|f| f.content()).unwrap_or(""); + fs::write(path, content).map_err(|e| DomainError::IoError(e.to_string()))?; + } + OutputPath::Directory(dir) => { + fs::create_dir_all(dir).map_err(|e| DomainError::IoError(e.to_string()))?; + + for file in output.files() { + let path = dir.join(file.name()); + fs::write(&path, file.content()) + .map_err(|e| DomainError::IoError(e.to_string()))?; + } + } + } + + Ok(()) + } +} diff --git a/crates/adapters/file-writer/src/lib.rs b/crates/adapters/file-writer/src/lib.rs new file mode 100644 index 0000000..97b1243 --- /dev/null +++ b/crates/adapters/file-writer/src/lib.rs @@ -0,0 +1,3 @@ +mod file_output_writer; + +pub use file_output_writer::FileOutputWriter; diff --git a/crates/adapters/file-writer/tests/file_writer_tests.rs b/crates/adapters/file-writer/tests/file_writer_tests.rs new file mode 100644 index 0000000..5315165 --- /dev/null +++ b/crates/adapters/file-writer/tests/file_writer_tests.rs @@ -0,0 +1,35 @@ +use std::fs; + +use archlens_domain::{RenderOutput, RenderedFile, ports::OutputWriter}; +use archlens_file_writer::FileOutputWriter; + +#[test] +fn writes_single_file_to_directory() { + let dir = tempfile::tempdir().unwrap(); + let writer = FileOutputWriter::new(dir.path().to_path_buf()); + + let file = RenderedFile::new("arch.mmd", "classDiagram").unwrap(); + let output = RenderOutput::single(file); + + writer.write(&output).unwrap(); + + let content = fs::read_to_string(dir.path().join("arch.mmd")).unwrap(); + assert_eq!(content, "classDiagram"); +} + +#[test] +fn writes_multiple_files_to_directory() { + let dir = tempfile::tempdir().unwrap(); + let writer = FileOutputWriter::new(dir.path().to_path_buf()); + + let files = vec![ + RenderedFile::new("overview.mmd", "graph TD").unwrap(), + RenderedFile::new("orders.mmd", "classDiagram").unwrap(), + ]; + let output = RenderOutput::new(files); + + writer.write(&output).unwrap(); + + assert!(dir.path().join("overview.mmd").exists()); + assert!(dir.path().join("orders.mmd").exists()); +} diff --git a/crates/adapters/mermaid/Cargo.toml b/crates/adapters/mermaid/Cargo.toml new file mode 100644 index 0000000..3a19309 --- /dev/null +++ b/crates/adapters/mermaid/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "archlens-mermaid" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true diff --git a/crates/adapters/mermaid/src/lib.rs b/crates/adapters/mermaid/src/lib.rs new file mode 100644 index 0000000..1e1e5ab --- /dev/null +++ b/crates/adapters/mermaid/src/lib.rs @@ -0,0 +1,3 @@ +mod mermaid_renderer; + +pub use mermaid_renderer::MermaidRenderer; diff --git a/crates/adapters/mermaid/src/mermaid_renderer.rs b/crates/adapters/mermaid/src/mermaid_renderer.rs new file mode 100644 index 0000000..8b70c5e --- /dev/null +++ b/crates/adapters/mermaid/src/mermaid_renderer.rs @@ -0,0 +1,266 @@ +use std::collections::{HashMap, HashSet}; + +use archlens_domain::{ + CodeElement, CodeGraph, DiagramLevel, DomainError, RelationshipKind, RenderOutput, + RenderedFile, Visibility, ports::DiagramRenderer, +}; + +pub struct MermaidRenderer { + level: DiagramLevel, +} + +impl Default for MermaidRenderer { + fn default() -> Self { + Self::new() + } +} + +impl MermaidRenderer { + pub fn new() -> Self { + Self { + level: DiagramLevel::Type, + } + } + + pub fn with_level(level: DiagramLevel) -> Self { + Self { level } + } + + fn format_element_name(element: &CodeElement) -> String { + let name = element.name(); + if element.generics().is_empty() { + name.to_string() + } else { + format!("{}~{}~", name, element.generics().join(", ")) + } + } + + fn format_visibility(visibility: Visibility) -> &'static str { + match visibility { + Visibility::Public => "public", + Visibility::Private => "private", + Visibility::Internal => "internal", + } + } + + fn render_class_diagram(&self, graph: &CodeGraph) -> String { + let mut lines = vec!["classDiagram".to_string()]; + + let mut grouped: HashMap> = HashMap::new(); + let mut ungrouped: Vec<&CodeElement> = Vec::new(); + + for element in graph.elements() { + if let Some(module) = element.module() { + grouped + .entry(module.as_str().to_string()) + .or_default() + .push(element); + } else { + ungrouped.push(element); + } + } + + let has_namespaces = !grouped.is_empty(); + + let mut seen: HashSet = HashSet::new(); + for element in &ungrouped { + if seen.insert(element.name().to_string()) { + Self::push_class_lines(&mut lines, element, " "); + } + } + + if has_namespaces { + for (namespace, elements) in &grouped { + lines.push(format!(" namespace {namespace} {{")); + let mut ns_seen: HashSet = HashSet::new(); + for element in elements { + if ns_seen.insert(element.name().to_string()) { + Self::push_class_lines(&mut lines, element, " "); + } + } + lines.push(" }".to_string()); + } + } + + let mut rel_seen: HashSet = HashSet::new(); + for rel in graph.relationships() { + if rel.kind() == RelationshipKind::Import { + continue; + } + let arrow = match rel.kind() { + RelationshipKind::Inheritance => "<|--", + RelationshipKind::Composition => "-->", + RelationshipKind::Import => "..>", + }; + let key = format!("{} {} {}", rel.source(), arrow, rel.target()); + if rel_seen.insert(key.clone()) { + lines.push(format!(" {key}")); + } + } + + lines.join("\n") + } + + fn push_class_lines(lines: &mut Vec, element: &CodeElement, indent: &str) { + lines.push(format!( + "{indent}class {}", + Self::format_element_name(element) + )); + if element.visibility() != Visibility::Public { + lines.push(format!( + "{indent}<<{}>> {}", + Self::format_visibility(element.visibility()), + element.name() + )); + } + let name = element.name(); + for field in element.fields() { + lines.push(format!("{indent}{name} : {field}")); + } + for method in element.methods() { + lines.push(format!("{indent}{name} : {method}")); + } + } + + fn render_module_flowchart(&self, graph: &CodeGraph) -> String { + let mut lines = vec!["graph TD".to_string()]; + + let mut name_to_modules: HashMap<&str, HashSet<&str>> = HashMap::new(); + let mut file_to_module: HashMap = HashMap::new(); + let mut modules: HashSet = HashSet::new(); + + for element in graph.elements() { + if let Some(module) = element.module() { + name_to_modules + .entry(element.name()) + .or_default() + .insert(module.as_str()); + modules.insert(module.as_str().to_string()); + + let file_stem = std::path::Path::new(element.file_path().as_str()) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + if !file_stem.is_empty() { + file_to_module.insert(file_stem.to_string(), module.as_str().to_string()); + } + } + } + + for module in &modules { + lines.push(format!(" {module}[{module}]")); + } + + let mut module_edges: HashSet<(String, String)> = HashSet::new(); + for rel in graph.relationships() { + match rel.kind() { + RelationshipKind::Import => { + let source_mod = file_to_module.get(rel.source()); + let target_top = rel.target().split('.').next().unwrap_or(""); + let target_mod = Self::capitalize(target_top); + + if let Some(src) = source_mod + && modules.contains(&target_mod) + && *src != target_mod + { + module_edges.insert((src.clone(), target_mod)); + } + } + _ => { + if modules.contains(rel.source()) + && modules.contains(rel.target()) + && rel.source() != rel.target() + { + module_edges.insert((rel.source().to_string(), rel.target().to_string())); + continue; + } + + let src_mods = name_to_modules.get(rel.source()); + let tgt_mods = name_to_modules.get(rel.target()); + + if let (Some(src_set), Some(tgt_set)) = (src_mods, tgt_mods) { + for src_mod in src_set { + if tgt_set.contains(src_mod) { + continue; + } + for tgt_mod in tgt_set { + if src_mod != tgt_mod { + module_edges.insert((src_mod.to_string(), tgt_mod.to_string())); + } + } + } + } + } + } + } + + for (source, target) in &module_edges { + lines.push(format!(" {source} --> {target}")); + } + + lines.join("\n") + } + + fn capitalize(s: &str) -> String { + if s.is_empty() { + return String::new(); + } + format!("{}{}", s[..1].to_uppercase(), &s[1..]) + } + + fn render_project_flowchart(&self, graph: &CodeGraph) -> String { + let mut lines = vec!["graph TD".to_string()]; + + let mut grouped: HashMap> = HashMap::new(); + let mut ungrouped: Vec<&CodeElement> = Vec::new(); + + for element in graph.elements() { + if let Some(module) = element.module() { + grouped + .entry(module.as_str().to_string()) + .or_default() + .push(element); + } else { + ungrouped.push(element); + } + } + + for element in &ungrouped { + let id = Self::sanitize_id(element.name()); + lines.push(format!(" {id}[{}]", element.name())); + } + + for (group, elements) in &grouped { + lines.push(format!(" subgraph {group}")); + for element in elements { + let id = Self::sanitize_id(element.name()); + lines.push(format!(" {id}[{}]", element.name())); + } + lines.push(" end".to_string()); + } + + for rel in graph.relationships() { + let source_id = Self::sanitize_id(rel.source()); + let target_id = Self::sanitize_id(rel.target()); + lines.push(format!(" {source_id} --> {target_id}")); + } + + lines.join("\n") + } + + fn sanitize_id(name: &str) -> String { + name.replace(['-', '.'], "_") + } +} + +impl DiagramRenderer for MermaidRenderer { + fn render(&self, graph: &CodeGraph) -> Result { + let content = match self.level { + DiagramLevel::Type => self.render_class_diagram(graph), + DiagramLevel::Module => self.render_module_flowchart(graph), + DiagramLevel::Project => self.render_project_flowchart(graph), + }; + let file = RenderedFile::new("diagram.mmd", &content)?; + Ok(RenderOutput::single(file)) + } +} diff --git a/crates/adapters/mermaid/tests/mermaid_renderer_tests.rs b/crates/adapters/mermaid/tests/mermaid_renderer_tests.rs new file mode 100644 index 0000000..f32b00a --- /dev/null +++ b/crates/adapters/mermaid/tests/mermaid_renderer_tests.rs @@ -0,0 +1,328 @@ +use archlens_domain::{ + CodeElement, CodeElementKind, CodeGraph, DiagramLevel, FilePath, ModuleName, Relationship, + RelationshipKind, Visibility, ports::DiagramRenderer, +}; +use archlens_mermaid::MermaidRenderer; + +fn build_type_level_graph() -> CodeGraph { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/service.rs").unwrap(), + 1, + ) + .unwrap(), + ); + graph.add_element( + CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/order.rs").unwrap(), + 1, + ) + .unwrap(), + ); + graph.add_relationship( + Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(), + ); + graph +} + +#[test] +fn renders_class_diagram_with_elements_and_composition() { + let renderer = MermaidRenderer::new(); + let output = renderer.render(&build_type_level_graph()).unwrap(); + + let content = output.files()[0].content(); + assert!(content.contains("classDiagram")); + assert!(content.contains("class OrderService")); + assert!(content.contains("class Order")); + assert!(content.contains("OrderService --> Order")); +} + +#[test] +fn inheritance_uses_different_arrow() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "Animal", + CodeElementKind::Class, + FilePath::new("src/animal.rs").unwrap(), + 1, + ) + .unwrap(), + ); + graph.add_element( + CodeElement::new( + "Dog", + CodeElementKind::Class, + FilePath::new("src/dog.rs").unwrap(), + 1, + ) + .unwrap(), + ); + graph.add_relationship( + Relationship::new("Dog", "Animal", RelationshipKind::Inheritance).unwrap(), + ); + + let renderer = MermaidRenderer::new(); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("Dog <|-- Animal")); +} + +#[test] +fn elements_show_kind_and_generics() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "Repository", + CodeElementKind::Trait, + FilePath::new("src/repo.rs").unwrap(), + 1, + ) + .unwrap() + .with_generics(vec!["T".to_string()]), + ); + + let renderer = MermaidRenderer::new(); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("class Repository~T~")); +} + +#[test] +fn private_elements_show_visibility_annotation() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "InternalHelper", + CodeElementKind::Class, + FilePath::new("src/helper.rs").unwrap(), + 1, + ) + .unwrap() + .with_visibility(Visibility::Private), + ); + + let renderer = MermaidRenderer::new(); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("class InternalHelper")); + assert!(content.contains("<>")); +} + +#[test] +fn renders_module_level_flowchart() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/orders/service.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Orders").unwrap()), + ); + graph.add_element( + CodeElement::new( + "BillingService", + CodeElementKind::Class, + FilePath::new("src/billing/service.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Billing").unwrap()), + ); + graph.add_relationship( + Relationship::new( + "OrderService", + "BillingService", + RelationshipKind::Composition, + ) + .unwrap(), + ); + + let renderer = MermaidRenderer::with_level(DiagramLevel::Module); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("graph TD")); + assert!(content.contains("Orders")); + assert!(content.contains("Billing")); + assert!(content.contains("Orders --> Billing")); +} + +#[test] +fn empty_graph_produces_valid_diagram() { + let renderer = MermaidRenderer::new(); + let output = renderer.render(&CodeGraph::new()).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("classDiagram")); +} + +#[test] +fn project_level_renders_subgraphs_for_grouped_projects() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "myapp-domain", + CodeElementKind::Project, + FilePath::new("crates/domain/Cargo.toml").unwrap(), + 1, + ) + .unwrap(), + ); + graph.add_element( + CodeElement::new( + "myapp-sqlite", + CodeElementKind::Project, + FilePath::new("crates/adapters/sqlite/Cargo.toml").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Adapters").unwrap()), + ); + graph.add_element( + CodeElement::new( + "myapp-nats", + CodeElementKind::Project, + FilePath::new("crates/adapters/nats/Cargo.toml").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Adapters").unwrap()), + ); + graph.add_relationship( + Relationship::new( + "myapp-sqlite", + "myapp-domain", + RelationshipKind::Composition, + ) + .unwrap(), + ); + + let renderer = MermaidRenderer::with_level(DiagramLevel::Project); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("graph TD")); + assert!(content.contains("subgraph Adapters")); + assert!(content.contains("myapp-sqlite")); + assert!(content.contains("myapp-nats")); + assert!(content.contains("myapp-domain")); + assert!(content.contains("-->")); +} + +#[test] +fn type_level_groups_elements_by_module() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/orders/service.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Orders").unwrap()), + ); + graph.add_element( + CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/orders/order.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Orders").unwrap()), + ); + graph.add_element( + CodeElement::new( + "Invoice", + CodeElementKind::Struct, + FilePath::new("src/billing/invoice.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Billing").unwrap()), + ); + + let renderer = MermaidRenderer::new(); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + assert!(content.contains("namespace Orders")); + assert!(content.contains("namespace Billing")); + assert!(content.contains("OrderService")); + assert!(content.contains("Invoice")); +} + +#[test] +fn module_level_aggregates_cross_module_deps_into_single_arrow() { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/orders/service.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Orders").unwrap()), + ); + graph.add_element( + CodeElement::new( + "OrderRepo", + CodeElementKind::Trait, + FilePath::new("src/orders/repo.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Orders").unwrap()), + ); + graph.add_element( + CodeElement::new( + "DbPool", + CodeElementKind::Struct, + FilePath::new("src/infra/db.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Infra").unwrap()), + ); + graph.add_element( + CodeElement::new( + "SqliteRepo", + CodeElementKind::Struct, + FilePath::new("src/infra/sqlite.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Infra").unwrap()), + ); + // Two types in Orders depend on two types in Infra + graph.add_relationship( + Relationship::new("OrderService", "DbPool", RelationshipKind::Composition).unwrap(), + ); + graph.add_relationship( + Relationship::new("OrderRepo", "SqliteRepo", RelationshipKind::Composition).unwrap(), + ); + + let renderer = MermaidRenderer::with_level(DiagramLevel::Module); + let output = renderer.render(&graph).unwrap(); + let content = output.files()[0].content(); + + let arrow_count = content.matches("Orders --> Infra").count(); + assert_eq!( + arrow_count, 1, + "should have exactly one aggregated arrow, got:\n{content}" + ); +} diff --git a/crates/adapters/stdout-writer/Cargo.toml b/crates/adapters/stdout-writer/Cargo.toml new file mode 100644 index 0000000..3868477 --- /dev/null +++ b/crates/adapters/stdout-writer/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "archlens-stdout-writer" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true diff --git a/crates/adapters/stdout-writer/src/lib.rs b/crates/adapters/stdout-writer/src/lib.rs new file mode 100644 index 0000000..864099c --- /dev/null +++ b/crates/adapters/stdout-writer/src/lib.rs @@ -0,0 +1,3 @@ +mod stdout_output_writer; + +pub use stdout_output_writer::StdoutOutputWriter; diff --git a/crates/adapters/stdout-writer/src/stdout_output_writer.rs b/crates/adapters/stdout-writer/src/stdout_output_writer.rs new file mode 100644 index 0000000..5fcead6 --- /dev/null +++ b/crates/adapters/stdout-writer/src/stdout_output_writer.rs @@ -0,0 +1,24 @@ +use archlens_domain::{DomainError, RenderOutput, ports::OutputWriter}; + +pub struct StdoutOutputWriter; + +impl Default for StdoutOutputWriter { + fn default() -> Self { + Self::new() + } +} + +impl StdoutOutputWriter { + pub fn new() -> Self { + Self + } +} + +impl OutputWriter for StdoutOutputWriter { + fn write(&self, output: &RenderOutput) -> Result<(), DomainError> { + for file in output.files() { + println!("{}", file.content()); + } + Ok(()) + } +} diff --git a/crates/adapters/stdout-writer/tests/stdout_writer_tests.rs b/crates/adapters/stdout-writer/tests/stdout_writer_tests.rs new file mode 100644 index 0000000..f0f9bda --- /dev/null +++ b/crates/adapters/stdout-writer/tests/stdout_writer_tests.rs @@ -0,0 +1,12 @@ +use archlens_domain::{RenderOutput, RenderedFile, ports::OutputWriter}; +use archlens_stdout_writer::StdoutOutputWriter; + +#[test] +fn writes_without_error() { + let writer = StdoutOutputWriter::new(); + let file = RenderedFile::new("arch.mmd", "classDiagram").unwrap(); + let output = RenderOutput::single(file); + + let result = writer.write(&output); + assert!(result.is_ok()); +} diff --git a/crates/adapters/toml-config/Cargo.toml b/crates/adapters/toml-config/Cargo.toml new file mode 100644 index 0000000..986f099 --- /dev/null +++ b/crates/adapters/toml-config/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "archlens-toml-config" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true +toml.workspace = true +serde.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/adapters/toml-config/src/lib.rs b/crates/adapters/toml-config/src/lib.rs new file mode 100644 index 0000000..9e2db16 --- /dev/null +++ b/crates/adapters/toml-config/src/lib.rs @@ -0,0 +1,3 @@ +mod toml_config_loader; + +pub use toml_config_loader::TomlConfigLoader; diff --git a/crates/adapters/toml-config/src/toml_config_loader.rs b/crates/adapters/toml-config/src/toml_config_loader.rs new file mode 100644 index 0000000..e8738ad --- /dev/null +++ b/crates/adapters/toml-config/src/toml_config_loader.rs @@ -0,0 +1,81 @@ +use std::collections::HashMap; +use std::path::Path; + +use serde::Deserialize; + +use archlens_domain::{ + AnalysisConfig, DiagramLevel, DomainError, OutputConfig, ports::ConfigLoader, +}; + +#[derive(Debug, Deserialize, Default)] +struct RawConfig { + #[serde(default)] + analysis: RawAnalysis, + #[serde(default)] + output: RawOutput, + #[serde(default)] + modules: HashMap, +} + +#[derive(Debug, Deserialize, Default)] +struct RawAnalysis { + #[serde(default)] + exclude: Vec, + #[serde(default)] + level: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct RawOutput { + #[serde(default)] + #[allow(dead_code)] + format: Option, + #[serde(default)] + path: Option, + #[serde(default)] + split_by_module: bool, +} + +#[derive(Default)] +pub struct TomlConfigLoader { + raw: RawConfig, +} + +impl TomlConfigLoader { + pub fn from_path(path: &Path) -> Result { + let content = + std::fs::read_to_string(path).map_err(|e| DomainError::IoError(e.to_string()))?; + let raw: RawConfig = + toml::from_str(&content).map_err(|e| DomainError::ConfigError(e.to_string()))?; + Ok(Self { raw }) + } + + fn parse_level(level: &Option) -> DiagramLevel { + match level.as_deref() { + Some("type") => DiagramLevel::Type, + Some("project") => DiagramLevel::Project, + _ => DiagramLevel::Module, + } + } +} + +impl ConfigLoader for TomlConfigLoader { + fn load_analysis_config(&self) -> Result { + let config = AnalysisConfig::default() + .with_excludes(self.raw.analysis.exclude.clone()) + .with_level(Self::parse_level(&self.raw.analysis.level)) + .with_module_mappings(self.raw.modules.clone()); + Ok(config) + } + + fn load_output_config(&self) -> Result { + let mut config = + OutputConfig::default().with_split_by_module(self.raw.output.split_by_module); + + if let Some(path) = &self.raw.output.path { + config = config.with_output_path(path.clone()); + } + + Ok(config) + } +} diff --git a/crates/adapters/toml-config/tests/toml_config_tests.rs b/crates/adapters/toml-config/tests/toml_config_tests.rs new file mode 100644 index 0000000..0015abb --- /dev/null +++ b/crates/adapters/toml-config/tests/toml_config_tests.rs @@ -0,0 +1,68 @@ +use std::fs; + +use archlens_domain::{DiagramLevel, ports::ConfigLoader}; +use archlens_toml_config::TomlConfigLoader; + +#[test] +fn loads_analysis_config_from_toml_file() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("archlens.toml"); + fs::write( + &config_path, + r#" +[analysis] +exclude = ["tests/", "vendor/"] +level = "type" + +[modules] +"src/orders" = "Orders" +"src/billing" = "Billing" +"#, + ) + .unwrap(); + + let loader = TomlConfigLoader::from_path(&config_path).unwrap(); + let config = loader.load_analysis_config().unwrap(); + + assert_eq!(config.excludes(), &["tests/", "vendor/"]); + assert_eq!(config.level(), DiagramLevel::Type); + assert_eq!( + config.module_mappings().get("src/orders").unwrap(), + "Orders" + ); +} + +#[test] +fn loads_output_config_from_toml_file() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("archlens.toml"); + fs::write( + &config_path, + r#" +[output] +format = "mermaid" +path = "docs/arch.mmd" +split_by_module = true +"#, + ) + .unwrap(); + + let loader = TomlConfigLoader::from_path(&config_path).unwrap(); + let config = loader.load_output_config().unwrap(); + + assert!(config.split_by_module()); + assert_eq!(config.output_path(), Some("docs/arch.mmd")); +} + +#[test] +fn missing_file_returns_defaults() { + let loader = TomlConfigLoader::default(); + + let analysis = loader.load_analysis_config().unwrap(); + assert!(analysis.excludes().is_empty()); + assert_eq!(analysis.level(), DiagramLevel::Module); + + let output = loader.load_output_config().unwrap(); + assert!(!output.split_by_module()); + assert!(output.output_path().is_none()); +} diff --git a/crates/adapters/tree-sitter/Cargo.toml b/crates/adapters/tree-sitter/Cargo.toml new file mode 100644 index 0000000..1fe9a44 --- /dev/null +++ b/crates/adapters/tree-sitter/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "archlens-tree-sitter" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true +tree-sitter.workspace = true +tree-sitter-rust.workspace = true +tree-sitter-python.workspace = true +tree-sitter-c-sharp.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/adapters/tree-sitter/src/lib.rs b/crates/adapters/tree-sitter/src/lib.rs new file mode 100644 index 0000000..b2f45c4 --- /dev/null +++ b/crates/adapters/tree-sitter/src/lib.rs @@ -0,0 +1,5 @@ +mod python; +mod rust; +mod tree_sitter_analyzer; + +pub use tree_sitter_analyzer::TreeSitterAnalyzer; diff --git a/crates/adapters/tree-sitter/src/python/mod.rs b/crates/adapters/tree-sitter/src/python/mod.rs new file mode 100644 index 0000000..a4d6a74 --- /dev/null +++ b/crates/adapters/tree-sitter/src/python/mod.rs @@ -0,0 +1,337 @@ +use std::collections::HashSet; + +use tree_sitter::{Node, Parser}; + +use archlens_domain::{ + AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath, + Relationship, RelationshipKind, +}; + +pub fn analyze(source: &str, file_path: &FilePath) -> Result { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_python::LANGUAGE.into()) + .map_err(|e| DomainError::AnalysisError(e.to_string()))?; + + let tree = parser + .parse(source, None) + .ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?; + + let mut elements = Vec::new(); + let mut relationships = Vec::new(); + let mut warnings = Vec::new(); + let mut type_names: HashSet = HashSet::new(); + + let root = tree.root_node(); + collect_classes( + &root, + source, + file_path, + &mut elements, + &mut type_names, + &mut relationships, + &mut warnings, + ); + collect_imports(&root, source, file_path, &mut relationships); + + let relationships = relationships + .into_iter() + .map(|r| r.with_source_file(file_path.clone())) + .collect(); + + Ok(AnalysisResult::new(elements, relationships, warnings)) +} + +fn collect_classes( + node: &Node, + source: &str, + file_path: &FilePath, + elements: &mut Vec, + type_names: &mut HashSet, + relationships: &mut Vec, + warnings: &mut Vec, +) { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() != "class_definition" { + continue; + } + + let Some(name_node) = child.child_by_field_name("name") else { + continue; + }; + + let name = &source[name_node.byte_range()]; + let line = child.start_position().row + 1; + + match CodeElement::new(name, CodeElementKind::Class, file_path.clone(), line) { + Ok(element) => { + type_names.insert(name.to_string()); + elements.push(element); + } + Err(e) => { + if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) { + warnings.push(w); + } + continue; + } + } + + if let Some(superclasses) = child.child_by_field_name("superclasses") { + collect_inheritance(&superclasses, source, name, type_names, relationships); + } + + if let Some(body) = child.child_by_field_name("body") { + collect_typed_fields(&body, source, name, type_names, relationships); + collect_constructor_params(&body, source, name, type_names, relationships); + } + } +} + +fn collect_inheritance( + superclasses: &Node, + source: &str, + class_name: &str, + _type_names: &HashSet, + relationships: &mut Vec, +) { + let mut cursor = superclasses.walk(); + for child in superclasses.children(&mut cursor) { + if child.kind() == "identifier" { + let base_name = &source[child.byte_range()]; + if !is_python_builtin(base_name) + && let Ok(rel) = + Relationship::new(class_name, base_name, RelationshipKind::Inheritance) + { + relationships.push(rel); + } + } + } +} + +const PYTHON_BUILTINS: &[&str] = &[ + "str", + "int", + "float", + "bool", + "bytes", + "list", + "dict", + "set", + "tuple", + "None", + "type", + "object", + "Exception", + "BaseException", + "Optional", + "Any", + "Union", + "List", + "Dict", + "Set", + "Tuple", + "Callable", + "Sequence", + "Mapping", + "Iterable", + "Iterator", + "Generator", + "Coroutine", + "AsyncGenerator", + "ClassVar", + "Final", + "Literal", + "TypeVar", + "Generic", + "Protocol", + "runtime_checkable", + "Self", +]; + +fn is_python_builtin(name: &str) -> bool { + PYTHON_BUILTINS.contains(&name) +} + +const STDLIB_MODULES: &[&str] = &[ + "os", + "sys", + "typing", + "logging", + "json", + "re", + "io", + "abc", + "collections", + "datetime", + "enum", + "functools", + "hashlib", + "http", + "importlib", + "inspect", + "itertools", + "math", + "pathlib", + "pickle", + "random", + "shutil", + "signal", + "socket", + "string", + "subprocess", + "tempfile", + "threading", + "time", + "traceback", + "unittest", + "urllib", + "uuid", + "warnings", + "contextlib", + "dataclasses", + "copy", + "struct", + "base64", + "csv", + "glob", + "operator", + "textwrap", + "asyncio", + "concurrent", + "multiprocessing", +]; + +fn is_external_import(module: &str) -> bool { + let top = module.split('.').next().unwrap_or(module); + if STDLIB_MODULES.contains(&top) { + return true; + } + if top.starts_with('_') { + return true; + } + false +} + +fn collect_imports( + node: &Node, + source: &str, + file_path: &FilePath, + relationships: &mut Vec, +) { + let file_name = std::path::Path::new(file_path.as_str()) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + match child.kind() { + "import_statement" => { + let mut name_cursor = child.walk(); + for name_child in child.children(&mut name_cursor) { + if name_child.kind() == "dotted_name" { + let module = &source[name_child.byte_range()]; + if !is_external_import(module) + && let Ok(rel) = + Relationship::new(file_name, module, RelationshipKind::Import) + { + relationships.push(rel); + } + } + } + } + "import_from_statement" => { + if let Some(module_node) = child.child_by_field_name("module_name") { + let module = &source[module_node.byte_range()]; + if !is_external_import(module) + && let Ok(rel) = + Relationship::new(file_name, module, RelationshipKind::Import) + { + relationships.push(rel); + } + } + } + _ => {} + } + } +} + +fn collect_constructor_params( + body: &Node, + source: &str, + class_name: &str, + _type_names: &HashSet, + relationships: &mut Vec, +) { + let mut cursor = body.walk(); + for child in body.children(&mut cursor) { + if child.kind() != "function_definition" { + continue; + } + let Some(fn_name) = child.child_by_field_name("name") else { + continue; + }; + if &source[fn_name.byte_range()] != "__init__" { + continue; + } + let Some(params) = child.child_by_field_name("parameters") else { + continue; + }; + let mut param_cursor = params.walk(); + for param in params.children(&mut param_cursor) { + if param.kind() == "typed_parameter" + && let Some(type_node) = param.child_by_field_name("type") + { + let type_text = &source[type_node.byte_range()]; + let base_type = type_text.split('[').next().unwrap_or(type_text).trim(); + if base_type != class_name + && !base_type.is_empty() + && !is_python_builtin(base_type) + && let Ok(rel) = + Relationship::new(class_name, base_type, RelationshipKind::Composition) + { + relationships.push(rel); + } + } + } + } +} + +fn collect_typed_fields( + body: &Node, + source: &str, + class_name: &str, + type_names: &HashSet, + relationships: &mut Vec, +) { + collect_typed_fields_recursive(body, source, class_name, type_names, relationships); +} + +fn collect_typed_fields_recursive( + node: &Node, + source: &str, + class_name: &str, + _type_names: &HashSet, + relationships: &mut Vec, +) { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if (child.kind() == "assignment" || child.kind() == "typed_assignment") + && let Some(type_node) = child.child_by_field_name("type") + { + let type_text = &source[type_node.byte_range()]; + let base_type = type_text.split('[').next().unwrap_or(type_text).trim(); + if base_type != class_name + && !base_type.is_empty() + && !is_python_builtin(base_type) + && let Ok(rel) = + Relationship::new(class_name, base_type, RelationshipKind::Composition) + { + relationships.push(rel); + } + } + + collect_typed_fields_recursive(&child, source, class_name, _type_names, relationships); + } +} diff --git a/crates/adapters/tree-sitter/src/rust/mod.rs b/crates/adapters/tree-sitter/src/rust/mod.rs new file mode 100644 index 0000000..36f350f --- /dev/null +++ b/crates/adapters/tree-sitter/src/rust/mod.rs @@ -0,0 +1,349 @@ +use std::collections::HashSet; + +use tree_sitter::{Node, Parser}; + +const RUST_PRIMITIVES: &[&str] = &[ + "bool", + "char", + "str", + "String", + "u8", + "u16", + "u32", + "u64", + "u128", + "usize", + "i8", + "i16", + "i32", + "i64", + "i128", + "isize", + "f32", + "f64", + "Vec", + "Option", + "Result", + "Box", + "Rc", + "Arc", + "HashMap", + "HashSet", + "BTreeMap", + "BTreeSet", + "PhantomData", + "Pin", + "Cow", + "Self", +]; + +use archlens_domain::{ + AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath, + Relationship, RelationshipKind, Visibility, +}; + +pub fn analyze(source: &str, file_path: &FilePath) -> Result { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_rust::LANGUAGE.into()) + .map_err(|e| DomainError::AnalysisError(e.to_string()))?; + + let tree = parser + .parse(source, None) + .ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?; + + let mut elements = Vec::new(); + let mut relationships = Vec::new(); + let mut warnings = Vec::new(); + let mut type_names: HashSet = HashSet::new(); + + let root = tree.root_node(); + collect_types( + &root, + source, + file_path, + &mut elements, + &mut type_names, + &mut warnings, + ); + collect_relationships( + &root, + source, + &type_names, + &mut relationships, + &mut warnings, + ); + collect_mod_declarations(&root, source, file_path, &mut relationships); + collect_use_imports(&root, source, file_path, &mut relationships); + + let relationships = relationships + .into_iter() + .map(|r| r.with_source_file(file_path.clone())) + .collect(); + + Ok(AnalysisResult::new(elements, relationships, warnings)) +} + +fn collect_types( + node: &Node, + source: &str, + file_path: &FilePath, + elements: &mut Vec, + type_names: &mut HashSet, + warnings: &mut Vec, +) { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + let (kind, name_field) = match child.kind() { + "struct_item" => (CodeElementKind::Struct, "name"), + "enum_item" => (CodeElementKind::Enum, "name"), + "trait_item" => (CodeElementKind::Trait, "name"), + _ => continue, + }; + + if let Some(name_node) = child.child_by_field_name(name_field) { + let name = &source[name_node.byte_range()]; + let line = child.start_position().row + 1; + let visibility = detect_visibility(&child, source); + + match CodeElement::new(name, kind, file_path.clone(), line) { + Ok(element) => { + let fields = extract_fields(&child, source); + let methods = extract_methods(node, source, name); + let element = element + .with_visibility(visibility) + .with_fields(fields) + .with_methods(methods); + type_names.insert(name.to_string()); + elements.push(element); + } + Err(e) => { + if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) { + warnings.push(w); + } + } + } + } + } +} + +fn collect_relationships( + node: &Node, + source: &str, + type_names: &HashSet, + relationships: &mut Vec, + warnings: &mut Vec, +) { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + match child.kind() { + "struct_item" => { + if let Some(name_node) = child.child_by_field_name("name") { + let struct_name = source[name_node.byte_range()].to_string(); + collect_field_compositions( + &child, + source, + &struct_name, + type_names, + relationships, + ); + } + } + "impl_item" => { + collect_trait_impl(&child, source, type_names, relationships, warnings); + } + _ => {} + } + } +} + +fn collect_field_compositions( + struct_node: &Node, + source: &str, + struct_name: &str, + _type_names: &HashSet, + relationships: &mut Vec, +) { + if let Some(body) = struct_node.child_by_field_name("body") { + let mut cursor = body.walk(); + for field in body.children(&mut cursor) { + if field.kind() == "field_declaration" + && let Some(type_node) = field.child_by_field_name("type") + { + let type_text = extract_base_type(&type_node, source); + if type_text != struct_name + && !type_text.is_empty() + && !RUST_PRIMITIVES.contains(&type_text.as_str()) + && let Ok(rel) = + Relationship::new(struct_name, &type_text, RelationshipKind::Composition) + { + relationships.push(rel); + } + } + } + } +} + +fn collect_trait_impl( + impl_node: &Node, + source: &str, + _type_names: &HashSet, + relationships: &mut Vec, + _warnings: &mut Vec, +) { + let trait_node = impl_node.child_by_field_name("trait"); + let type_node = impl_node.child_by_field_name("type"); + + if let (Some(trait_n), Some(type_n)) = (trait_node, type_node) { + let trait_name = extract_base_type(&trait_n, source); + let type_name = extract_base_type(&type_n, source); + + if !trait_name.is_empty() + && !type_name.is_empty() + && !RUST_PRIMITIVES.contains(&trait_name.as_str()) + && !RUST_PRIMITIVES.contains(&type_name.as_str()) + && let Ok(rel) = + Relationship::new(&type_name, &trait_name, RelationshipKind::Inheritance) + { + relationships.push(rel); + } + } +} + +fn extract_fields(node: &Node, source: &str) -> Vec { + let mut fields = Vec::new(); + if let Some(body) = node.child_by_field_name("body") { + let mut cursor = body.walk(); + for child in body.children(&mut cursor) { + if child.kind() == "field_declaration" { + let name = child + .child_by_field_name("name") + .map(|n| &source[n.byte_range()]); + let ty = child + .child_by_field_name("type") + .map(|n| extract_base_type(&n, source)); + if let (Some(name), Some(ty)) = (name, ty) { + fields.push(format!("{name}: {ty}")); + } + } + } + } + fields +} + +fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec { + let mut methods = Vec::new(); + let mut cursor = root.walk(); + for child in root.children(&mut cursor) { + if child.kind() != "impl_item" { + continue; + } + if child.child_by_field_name("trait").is_some() { + continue; + } + let type_node = child.child_by_field_name("type"); + if let Some(tn) = type_node { + let impl_name = extract_base_type(&tn, source); + if impl_name != type_name { + continue; + } + } + if let Some(body) = child.child_by_field_name("body") { + let mut body_cursor = body.walk(); + for item in body.children(&mut body_cursor) { + if item.kind() == "function_item" + && let Some(name_node) = item.child_by_field_name("name") + { + let fn_name = &source[name_node.byte_range()]; + let vis = if detect_visibility(&item, source) == Visibility::Public { + "+" + } else { + "-" + }; + methods.push(format!("{vis}{fn_name}()")); + } + } + } + } + methods +} + +fn collect_mod_declarations( + node: &Node, + source: &str, + file_path: &FilePath, + relationships: &mut Vec, +) { + let file_name = std::path::Path::new(file_path.as_str()) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() != "mod_item" { + continue; + } + if child.child_by_field_name("body").is_some() { + continue; + } + if let Some(name_node) = child.child_by_field_name("name") { + let mod_name = &source[name_node.byte_range()]; + let target = format!("crate::{mod_name}"); + if let Ok(rel) = Relationship::new(file_name, &target, RelationshipKind::Import) { + relationships.push(rel); + } + } + } +} + +fn collect_use_imports( + node: &Node, + source: &str, + file_path: &FilePath, + relationships: &mut Vec, +) { + let file_name = std::path::Path::new(file_path.as_str()) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() != "use_declaration" { + continue; + } + if let Some(arg) = child.child_by_field_name("argument") { + let text = &source[arg.byte_range()]; + let path = text + .split('{') + .next() + .unwrap_or(text) + .trim_end_matches("::"); + + if (path.starts_with("crate::") || path.starts_with("super::")) + && let Ok(rel) = Relationship::new(file_name, path, RelationshipKind::Import) + { + relationships.push(rel); + } + } + } +} + +fn extract_base_type(node: &Node, source: &str) -> String { + let text = &source[node.byte_range()]; + text.split('<').next().unwrap_or(text).trim().to_string() +} + +fn detect_visibility(node: &Node, source: &str) -> Visibility { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "visibility_modifier" { + let text = &source[child.byte_range()]; + if text.contains("pub") { + return Visibility::Public; + } + } + } + Visibility::Private +} diff --git a/crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs b/crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs new file mode 100644 index 0000000..30806f6 --- /dev/null +++ b/crates/adapters/tree-sitter/src/tree_sitter_analyzer.rs @@ -0,0 +1,30 @@ +use archlens_domain::{AnalysisResult, DomainError, Language, SourceFile, ports::SourceAnalyzer}; + +use crate::{python, rust}; + +pub struct TreeSitterAnalyzer; + +impl Default for TreeSitterAnalyzer { + fn default() -> Self { + Self::new() + } +} + +impl TreeSitterAnalyzer { + pub fn new() -> Self { + Self + } +} + +impl SourceAnalyzer for TreeSitterAnalyzer { + fn analyze_file(&self, file: &SourceFile) -> Result { + let source = std::fs::read_to_string(file.path().as_str()) + .map_err(|e| DomainError::IoError(e.to_string()))?; + + match file.language() { + Language::Rust => rust::analyze(&source, file.path()), + Language::Python => python::analyze(&source, file.path()), + Language::CSharp => Ok(AnalysisResult::empty()), + } + } +} diff --git a/crates/adapters/tree-sitter/tests/python_analyzer_tests.rs b/crates/adapters/tree-sitter/tests/python_analyzer_tests.rs new file mode 100644 index 0000000..8956064 --- /dev/null +++ b/crates/adapters/tree-sitter/tests/python_analyzer_tests.rs @@ -0,0 +1,138 @@ +use archlens_domain::{ + CodeElementKind, FilePath, Language, RelationshipKind, SourceFile, ports::SourceAnalyzer, +}; +use archlens_tree_sitter::TreeSitterAnalyzer; + +fn analyze_python(source: &str, filename: &str) -> archlens_domain::AnalysisResult { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join(filename); + std::fs::write(&file_path, source).unwrap(); + + let analyzer = TreeSitterAnalyzer::new(); + let source_file = SourceFile::new( + FilePath::new(file_path.to_str().unwrap()).unwrap(), + Language::Python, + ); + analyzer.analyze_file(&source_file).unwrap() +} + +#[test] +fn extracts_python_class() { + let result = analyze_python("class Order:\n pass\n", "order.py"); + + assert_eq!(result.elements().len(), 1); + assert_eq!(result.elements()[0].name(), "Order"); + assert_eq!(result.elements()[0].kind(), CodeElementKind::Class); +} + +#[test] +fn extracts_python_inheritance() { + let source = "class Animal:\n pass\n\nclass Dog(Animal):\n pass\n"; + let result = analyze_python(source, "animals.py"); + + assert_eq!(result.elements().len(), 2); + + let inheritance: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Inheritance) + .collect(); + + assert_eq!(inheritance.len(), 1); + assert_eq!(inheritance[0].source(), "Dog"); + assert_eq!(inheritance[0].target(), "Animal"); +} + +#[test] +fn extracts_composition_from_type_annotated_fields() { + let source = "class Address:\n pass\n\nclass User:\n def __init__(self):\n self.address: Address = Address()\n"; + let result = analyze_python(source, "user.py"); + + let composition: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Composition) + .collect(); + + assert_eq!(composition.len(), 1); + assert_eq!(composition[0].source(), "User"); + assert_eq!(composition[0].target(), "Address"); +} + +#[test] +fn extracts_import_from_import_statement() { + let source = "import os\nfrom commons.src.schema import BaseModel\n"; + let result = analyze_python(source, "service.py"); + + let imports: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Import) + .collect(); + + assert!(imports.iter().any(|r| r.target() == "commons.src.schema")); + assert!( + !imports.iter().any(|r| r.target() == "os"), + "stdlib should be filtered" + ); +} + +#[test] +fn extracts_relative_imports_from_init() { + let source = "from .schema import BaseModel\nfrom .client import ApiClient\n"; + let result = analyze_python(source, "__init__.py"); + + let imports: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Import) + .collect(); + + assert_eq!(imports.len(), 2); +} + +#[test] +fn extracts_import_from_plain_import() { + let source = "import commons.utils\n"; + let result = analyze_python(source, "service.py"); + + let imports: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Import) + .collect(); + + assert!(imports.iter().any(|r| r.target() == "commons.utils")); +} + +#[test] +fn extracts_composition_from_constructor_params() { + let source = "class Config:\n pass\n\nclass Service:\n def __init__(self, config: Config):\n pass\n"; + let result = analyze_python(source, "service.py"); + + let composition: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Composition) + .collect(); + + assert_eq!(composition.len(), 1); + assert_eq!(composition[0].source(), "Service"); + assert_eq!(composition[0].target(), "Config"); +} + +#[test] +fn extracts_composition_from_class_level_annotations() { + let source = "class Gad:\n pass\n\nclass Definition:\n gad: Gad\n name: str\n"; + let result = analyze_python(source, "models.py"); + + let composition: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Composition) + .collect(); + + assert_eq!(composition.len(), 1); + assert_eq!(composition[0].source(), "Definition"); + assert_eq!(composition[0].target(), "Gad"); +} diff --git a/crates/adapters/tree-sitter/tests/rust_analyzer_tests.rs b/crates/adapters/tree-sitter/tests/rust_analyzer_tests.rs new file mode 100644 index 0000000..f941671 --- /dev/null +++ b/crates/adapters/tree-sitter/tests/rust_analyzer_tests.rs @@ -0,0 +1,130 @@ +use archlens_domain::{ + CodeElementKind, FilePath, Language, RelationshipKind, SourceFile, ports::SourceAnalyzer, +}; +use archlens_tree_sitter::TreeSitterAnalyzer; + +fn analyze_rust(source: &str, filename: &str) -> archlens_domain::AnalysisResult { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join(filename); + std::fs::write(&file_path, source).unwrap(); + + let analyzer = TreeSitterAnalyzer::new(); + let source_file = SourceFile::new( + FilePath::new(file_path.to_str().unwrap()).unwrap(), + Language::Rust, + ); + analyzer.analyze_file(&source_file).unwrap() +} + +#[test] +fn extracts_rust_struct() { + let result = analyze_rust("pub struct Order {\n id: u64,\n}", "order.rs"); + + assert_eq!(result.elements().len(), 1); + assert_eq!(result.elements()[0].name(), "Order"); + assert_eq!(result.elements()[0].kind(), CodeElementKind::Struct); +} + +#[test] +fn extracts_rust_enum() { + let result = analyze_rust( + "pub enum Status {\n Active,\n Inactive,\n}", + "status.rs", + ); + + assert_eq!(result.elements().len(), 1); + assert_eq!(result.elements()[0].name(), "Status"); + assert_eq!(result.elements()[0].kind(), CodeElementKind::Enum); +} + +#[test] +fn extracts_rust_trait() { + let result = analyze_rust("pub trait Repository {\n fn find(&self);\n}", "repo.rs"); + + assert_eq!(result.elements().len(), 1); + assert_eq!(result.elements()[0].name(), "Repository"); + assert_eq!(result.elements()[0].kind(), CodeElementKind::Trait); +} + +#[test] +fn extracts_composition_from_struct_fields() { + let source = + "pub struct Order {\n id: u64,\n}\npub struct OrderService {\n order: Order,\n}"; + let result = analyze_rust(source, "service.rs"); + + assert_eq!(result.elements().len(), 2); + assert_eq!(result.relationships().len(), 1); + assert_eq!(result.relationships()[0].source(), "OrderService"); + assert_eq!(result.relationships()[0].target(), "Order"); + assert_eq!( + result.relationships()[0].kind(), + RelationshipKind::Composition + ); +} + +#[test] +fn extracts_inheritance_from_trait_impl() { + let source = "pub trait Printable {}\npub struct Order {}\nimpl Printable for Order {}"; + let result = analyze_rust(source, "order.rs"); + + let inheritance: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Inheritance) + .collect(); + + assert_eq!(inheritance.len(), 1); + assert_eq!(inheritance[0].source(), "Order"); + assert_eq!(inheritance[0].target(), "Printable"); +} + +#[test] +fn extracts_use_imports() { + let source = + "use crate::domain::Order;\nuse crate::ports::Repository;\n\npub struct Service {}"; + let result = analyze_rust(source, "service.rs"); + + let imports: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Import) + .collect(); + + assert!(imports.iter().any(|r| r.target() == "crate::domain::Order")); + assert!( + imports + .iter() + .any(|r| r.target() == "crate::ports::Repository") + ); +} + +#[test] +fn filters_std_and_external_crate_imports() { + let source = + "use std::collections::HashMap;\nuse serde::Serialize;\nuse crate::models::Order;\n"; + let result = analyze_rust(source, "lib.rs"); + + let imports: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Import) + .collect(); + + assert_eq!(imports.len(), 1); + assert_eq!(imports[0].target(), "crate::models::Order"); +} + +#[test] +fn extracts_mod_declarations() { + let source = "mod models;\nmod services;\npub struct App {}"; + let result = analyze_rust(source, "lib.rs"); + + let imports: Vec<_> = result + .relationships() + .iter() + .filter(|r| r.kind() == RelationshipKind::Import) + .collect(); + + assert!(imports.iter().any(|r| r.target() == "crate::models")); + assert!(imports.iter().any(|r| r.target() == "crate::services")); +} diff --git a/crates/adapters/walkdir/Cargo.toml b/crates/adapters/walkdir/Cargo.toml new file mode 100644 index 0000000..0062108 --- /dev/null +++ b/crates/adapters/walkdir/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "archlens-walkdir" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true +walkdir.workspace = true +ignore.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/adapters/walkdir/src/lib.rs b/crates/adapters/walkdir/src/lib.rs new file mode 100644 index 0000000..cd1da50 --- /dev/null +++ b/crates/adapters/walkdir/src/lib.rs @@ -0,0 +1,3 @@ +mod walkdir_discovery; + +pub use walkdir_discovery::WalkdirDiscovery; diff --git a/crates/adapters/walkdir/src/walkdir_discovery.rs b/crates/adapters/walkdir/src/walkdir_discovery.rs new file mode 100644 index 0000000..658536b --- /dev/null +++ b/crates/adapters/walkdir/src/walkdir_discovery.rs @@ -0,0 +1,100 @@ +use std::path::Path; + +use ignore::WalkBuilder; + +use archlens_domain::{ + AnalysisConfig, DomainError, FilePath, Language, SourceFile, ports::FileDiscovery, +}; + +const DEFAULT_EXCLUDES: &[&str] = &[ + ".venv", + "venv", + "node_modules", + "__pycache__", + ".git", + "target", + "bin", + "obj", + "dist", + ".tox", + ".eggs", +]; + +pub struct WalkdirDiscovery; + +impl Default for WalkdirDiscovery { + fn default() -> Self { + Self::new() + } +} + +impl WalkdirDiscovery { + pub fn new() -> Self { + Self + } + + fn detect_language(path: &Path) -> Option { + match path.extension()?.to_str()? { + "rs" => Some(Language::Rust), + "py" => Some(Language::Python), + "cs" => Some(Language::CSharp), + _ => None, + } + } + + fn is_excluded(path: &Path, root: &Path, excludes: &[String]) -> bool { + let relative = path.strip_prefix(root).unwrap_or(path); + let relative_str = relative.to_string_lossy(); + + for component in relative.components() { + let name = component.as_os_str().to_string_lossy(); + if DEFAULT_EXCLUDES.iter().any(|e| name == *e) { + return true; + } + } + + excludes + .iter() + .any(|exclude| relative_str.contains(exclude.trim_end_matches('/'))) + } +} + +impl FileDiscovery for WalkdirDiscovery { + fn discover( + &self, + root: &Path, + config: &AnalysisConfig, + ) -> Result, DomainError> { + let mut files = Vec::new(); + + let walker = WalkBuilder::new(root).hidden(true).git_ignore(true).build(); + + for entry in walker.filter_map(|e| e.ok()) { + let path = entry.path(); + + if !path.is_file() { + continue; + } + + if Self::is_excluded(path, root, config.excludes()) { + continue; + } + + if let Some(scope) = config.scope() { + let relative = path.strip_prefix(root).unwrap_or(path); + if !relative.starts_with(scope) { + continue; + } + } + + if let Some(language) = Self::detect_language(path) { + let absolute = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let file_path = FilePath::new(&absolute.to_string_lossy()) + .map_err(|e| DomainError::IoError(e.to_string()))?; + files.push(SourceFile::new(file_path, language)); + } + } + + Ok(files) + } +} diff --git a/crates/adapters/walkdir/tests/walkdir_discovery_tests.rs b/crates/adapters/walkdir/tests/walkdir_discovery_tests.rs new file mode 100644 index 0000000..b00a798 --- /dev/null +++ b/crates/adapters/walkdir/tests/walkdir_discovery_tests.rs @@ -0,0 +1,71 @@ +use std::fs; + +use archlens_domain::{AnalysisConfig, Language, ports::FileDiscovery}; +use archlens_walkdir::WalkdirDiscovery; + +fn create_test_tree(dir: &std::path::Path) { + fs::create_dir_all(dir.join("src/orders")).unwrap(); + fs::create_dir_all(dir.join("src/billing")).unwrap(); + fs::write(dir.join("src/orders/service.rs"), "struct OrderService;").unwrap(); + fs::write(dir.join("src/orders/model.py"), "class Order: pass").unwrap(); + fs::write(dir.join("src/billing/invoice.cs"), "class Invoice {}").unwrap(); + fs::write(dir.join("src/readme.txt"), "not source code").unwrap(); +} + +#[test] +fn discovers_rust_python_and_csharp_files() { + let dir = tempfile::tempdir().unwrap(); + create_test_tree(dir.path()); + + let discovery = WalkdirDiscovery::new(); + let files = discovery + .discover(dir.path(), &AnalysisConfig::default()) + .unwrap(); + + assert_eq!(files.len(), 3); + + let languages: Vec = files.iter().map(|f| f.language()).collect(); + assert!(languages.contains(&Language::Rust)); + assert!(languages.contains(&Language::Python)); + assert!(languages.contains(&Language::CSharp)); +} + +#[test] +fn ignores_non_source_files() { + let dir = tempfile::tempdir().unwrap(); + create_test_tree(dir.path()); + + let discovery = WalkdirDiscovery::new(); + let files = discovery + .discover(dir.path(), &AnalysisConfig::default()) + .unwrap(); + + let paths: Vec<&str> = files.iter().map(|f| f.path().as_str()).collect(); + assert!(!paths.iter().any(|p| p.ends_with(".txt"))); +} + +#[test] +fn respects_exclude_patterns() { + let dir = tempfile::tempdir().unwrap(); + create_test_tree(dir.path()); + + let config = AnalysisConfig::default().with_excludes(vec!["billing".to_string()]); + + let discovery = WalkdirDiscovery::new(); + let files = discovery.discover(dir.path(), &config).unwrap(); + + assert_eq!(files.len(), 2); + assert!(!files.iter().any(|f| f.path().as_str().contains("billing"))); +} + +#[test] +fn empty_directory_returns_no_files() { + let dir = tempfile::tempdir().unwrap(); + + let discovery = WalkdirDiscovery::new(); + let files = discovery + .discover(dir.path(), &AnalysisConfig::default()) + .unwrap(); + + assert!(files.is_empty()); +} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml new file mode 100644 index 0000000..9478a39 --- /dev/null +++ b/crates/application/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "archlens-application" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +archlens-domain.workspace = true +thiserror.workspace = true +tracing.workspace = true +rayon.workspace = true diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs new file mode 100644 index 0000000..84c032e --- /dev/null +++ b/crates/application/src/lib.rs @@ -0,0 +1 @@ +pub mod queries; diff --git a/crates/application/src/queries/analyze_codebase.rs b/crates/application/src/queries/analyze_codebase.rs new file mode 100644 index 0000000..ebfb132 --- /dev/null +++ b/crates/application/src/queries/analyze_codebase.rs @@ -0,0 +1,244 @@ +use std::collections::HashSet; +use std::path::Path; + +use rayon::prelude::*; + +use archlens_domain::{ + AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName, Relationship, + RelationshipKind, + ports::{FileDiscovery, SourceAnalyzer}, +}; + +pub struct AnalyzeCodebase +where + F: FileDiscovery + Send + Sync, + S: SourceAnalyzer, +{ + file_discovery: F, + source_analyzer: S, +} + +impl AnalyzeCodebase +where + F: FileDiscovery + Send + Sync, + S: SourceAnalyzer, +{ + pub fn new(file_discovery: F, source_analyzer: S) -> Self { + Self { + file_discovery, + source_analyzer, + } + } + + pub fn execute( + &self, + root: &Path, + config: &AnalysisConfig, + ) -> Result { + let files = self.file_discovery.discover(root, config)?; + + let file_results: Vec<(Vec, Vec, Vec)> = files + .par_iter() + .map(|file| match self.source_analyzer.analyze_file(file) { + Ok(result) => { + let module = infer_module(file.path().as_str(), root, config); + let elements: Vec = result + .elements() + .iter() + .map(|el| { + let mut el = el.clone(); + if el.module().is_none() + && let Some(ref m) = module + { + el = el.with_module(m.clone()); + } + el + }) + .collect(); + ( + elements, + result.relationships().to_vec(), + result.warnings().to_vec(), + ) + } + Err(err) => { + let mut warnings = Vec::new(); + if let Ok(warning) = + AnalysisWarning::new(file.path().clone(), 0, &err.to_string()) + { + warnings.push(warning); + } + (Vec::new(), Vec::new(), warnings) + } + }) + .collect(); + + let mut graph = CodeGraph::new(); + let mut warnings = Vec::new(); + for (elements, relationships, warns) in file_results { + for el in elements { + graph.add_element(el); + } + for rel in relationships { + graph.add_relationship(rel); + } + warnings.extend(warns); + } + + let graph = resolve_cross_file_relationships(graph); + let graph = filter_external_imports(graph, root); + + Ok(AnalyzeCodebaseResult { graph, warnings }) + } +} + +fn resolve_cross_file_relationships(graph: CodeGraph) -> CodeGraph { + use std::collections::HashMap; + + let mut file_types: HashMap> = HashMap::new(); + let mut name_modules: HashMap<&str, HashSet>> = HashMap::new(); + let all_type_names: HashSet<&str> = graph.elements().iter().map(|e| e.name()).collect(); + + for element in graph.elements() { + file_types + .entry(element.file_path().as_str().to_string()) + .or_default() + .insert(element.name().to_string()); + name_modules + .entry(element.name()) + .or_default() + .insert(element.module().map(|m| m.as_str())); + } + + let mut resolved = CodeGraph::new(); + for element in graph.elements() { + resolved.add_element(element.clone()); + } + for rel in graph.relationships() { + match rel.kind() { + RelationshipKind::Import => { + resolved.add_relationship(rel.clone()); + } + _ => { + if !all_type_names.contains(rel.source()) || !all_type_names.contains(rel.target()) + { + continue; + } + + if let Some(src_file) = rel.source_file() { + let file_key = src_file.as_str().to_string(); + if let Some(types_in_file) = file_types.get(&file_key) + && types_in_file.contains(rel.target()) + { + resolved.add_relationship(rel.clone()); + continue; + } + } + + let tgt_modules = &name_modules[rel.target()]; + if tgt_modules.len() == 1 { + resolved.add_relationship(rel.clone()); + } + } + } + } + resolved +} + +fn filter_external_imports(graph: CodeGraph, root: &Path) -> CodeGraph { + let known_dirs: HashSet = std::fs::read_dir(root) + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .filter_map(|e| e.file_name().into_string().ok()) + .map(|s| s.to_lowercase()) + .collect(); + + let module_names: HashSet = graph + .modules() + .iter() + .map(|m| m.as_str().to_lowercase()) + .collect(); + + let all_known: HashSet<&str> = known_dirs + .iter() + .map(|s| s.as_str()) + .chain(module_names.iter().map(|s| s.as_str())) + .collect(); + + let mut filtered = CodeGraph::new(); + for element in graph.elements() { + filtered.add_element(element.clone()); + } + for rel in graph.relationships() { + if rel.kind() == RelationshipKind::Import { + let target_top = rel.target().split('.').next().unwrap_or("").to_lowercase(); + if !all_known.contains(target_top.as_str()) { + continue; + } + } + filtered.add_relationship(rel.clone()); + } + filtered +} + +fn infer_module(file_path: &str, root: &Path, config: &AnalysisConfig) -> Option { + let relative = if let Some(stripped) = file_path.strip_prefix(root.to_str().unwrap_or("")) { + stripped.trim_start_matches('/') + } else { + file_path + }; + + for (pattern, module_name) in config.module_mappings() { + if relative.starts_with(pattern) { + return ModuleName::new(module_name).ok(); + } + } + + let parts: Vec<&str> = relative.split('/').collect(); + if parts.len() <= 1 { + return None; + } + + let module_dir = if parts[0] == "crates" && parts.len() > 2 { + // workspace: crates//src/... + parts[1] + } else if parts[0] == "src" && parts.len() > 2 { + // single project: src//... + parts[1] + } else if parts[0] != "src" && parts.len() > 1 { + parts[0] + } else { + return None; + }; + + let capitalized = module_dir + .split('-') + .map(|seg| { + if seg.is_empty() { + String::new() + } else { + format!("{}{}", seg[..1].to_uppercase(), &seg[1..]) + } + }) + .collect::>() + .join("-"); + + ModuleName::new(&capitalized).ok() +} + +pub struct AnalyzeCodebaseResult { + graph: CodeGraph, + warnings: Vec, +} + +impl AnalyzeCodebaseResult { + pub fn graph(&self) -> &CodeGraph { + &self.graph + } + + pub fn warnings(&self) -> &[AnalysisWarning] { + &self.warnings + } +} diff --git a/crates/application/src/queries/mod.rs b/crates/application/src/queries/mod.rs new file mode 100644 index 0000000..ee6e763 --- /dev/null +++ b/crates/application/src/queries/mod.rs @@ -0,0 +1,5 @@ +mod analyze_codebase; +mod render_diagrams; + +pub use analyze_codebase::{AnalyzeCodebase, AnalyzeCodebaseResult}; +pub use render_diagrams::RenderDiagrams; diff --git a/crates/application/src/queries/render_diagrams.rs b/crates/application/src/queries/render_diagrams.rs new file mode 100644 index 0000000..c39de64 --- /dev/null +++ b/crates/application/src/queries/render_diagrams.rs @@ -0,0 +1,45 @@ +use archlens_domain::{ + CodeGraph, DomainError, OutputConfig, + ports::{DiagramRenderer, OutputWriter}, +}; + +pub struct RenderDiagrams +where + R: DiagramRenderer, + W: OutputWriter, +{ + renderer: R, + writer: W, +} + +impl RenderDiagrams +where + R: DiagramRenderer, + W: OutputWriter, +{ + pub fn new(renderer: R, writer: W) -> Self { + Self { renderer, writer } + } + + pub fn execute(&self, graph: &CodeGraph, config: &OutputConfig) -> Result<(), DomainError> { + if config.split_by_module() { + let overview = self.renderer.render(graph)?; + self.writer.write(&overview)?; + + for module in graph.modules() { + let subgraph = graph.subgraph_by_module(&module); + let output = self.renderer.render(&subgraph)?; + self.writer.write(&output)?; + } + } else { + let output = self.renderer.render(graph)?; + self.writer.write(&output)?; + } + + Ok(()) + } + + pub fn writer(&self) -> &W { + &self.writer + } +} diff --git a/crates/application/tests/analyze_codebase_tests.rs b/crates/application/tests/analyze_codebase_tests.rs new file mode 100644 index 0000000..25406da --- /dev/null +++ b/crates/application/tests/analyze_codebase_tests.rs @@ -0,0 +1,344 @@ +mod fakes; + +use std::path::Path; + +use archlens_application::queries::AnalyzeCodebase; +use archlens_domain::{ + AnalysisConfig, AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, + FilePath, Language, Relationship, RelationshipKind, SourceFile, +}; + +use fakes::{FakeFileDiscovery, FakeSourceAnalyzer}; + +#[test] +fn analyzes_discovered_files_and_builds_code_graph() { + let files = vec![ + SourceFile::new(FilePath::new("src/order.rs").unwrap(), Language::Rust), + SourceFile::new(FilePath::new("src/service.rs").unwrap(), Language::Rust), + ]; + + let discovery = FakeFileDiscovery::new(files); + + let analyzer = FakeSourceAnalyzer::new() + .with_result( + "src/order.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/order.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![], + vec![], + ), + ) + .with_result( + "src/service.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/service.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![ + Relationship::new("OrderService", "Order", RelationshipKind::Composition) + .unwrap(), + ], + vec![], + ), + ); + + let use_case = AnalyzeCodebase::new(discovery, analyzer); + let result = use_case + .execute(Path::new("."), &AnalysisConfig::default()) + .unwrap(); + + assert_eq!(result.graph().elements().len(), 2); + assert_eq!(result.graph().relationships().len(), 1); + assert!(result.warnings().is_empty()); +} + +#[test] +fn empty_file_list_returns_empty_graph() { + let discovery = FakeFileDiscovery::empty(); + let analyzer = FakeSourceAnalyzer::new(); + + let use_case = AnalyzeCodebase::new(discovery, analyzer); + let result = use_case + .execute(Path::new("."), &AnalysisConfig::default()) + .unwrap(); + + assert!(result.graph().elements().is_empty()); + assert!(result.graph().relationships().is_empty()); + assert!(result.warnings().is_empty()); +} + +#[test] +fn aggregates_warnings_from_multiple_files() { + let files = vec![ + SourceFile::new(FilePath::new("src/a.rs").unwrap(), Language::Rust), + SourceFile::new(FilePath::new("src/b.rs").unwrap(), Language::Rust), + ]; + + let discovery = FakeFileDiscovery::new(files); + let analyzer = FakeSourceAnalyzer::new() + .with_result( + "src/a.rs", + AnalysisResult::new( + vec![], + vec![], + vec![ + AnalysisWarning::new(FilePath::new("src/a.rs").unwrap(), 10, "unknown macro") + .unwrap(), + ], + ), + ) + .with_result( + "src/b.rs", + AnalysisResult::new( + vec![], + vec![], + vec![ + AnalysisWarning::new( + FilePath::new("src/b.rs").unwrap(), + 5, + "unparseable block", + ) + .unwrap(), + ], + ), + ); + + let use_case = AnalyzeCodebase::new(discovery, analyzer); + let result = use_case + .execute(Path::new("."), &AnalysisConfig::default()) + .unwrap(); + + assert_eq!(result.warnings().len(), 2); +} + +#[test] +fn analysis_error_on_file_collects_warning_and_continues() { + let files = vec![ + SourceFile::new(FilePath::new("src/good.rs").unwrap(), Language::Rust), + SourceFile::new(FilePath::new("src/broken.rs").unwrap(), Language::Rust), + ]; + + let discovery = FakeFileDiscovery::new(files); + let analyzer = FakeSourceAnalyzer::new() + .with_result( + "src/good.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "Good", + CodeElementKind::Struct, + FilePath::new("src/good.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![], + vec![], + ), + ) + .with_error( + "src/broken.rs", + DomainError::AnalysisError("parse failed".to_string()), + ); + + let use_case = AnalyzeCodebase::new(discovery, analyzer); + let result = use_case + .execute(Path::new("."), &AnalysisConfig::default()) + .unwrap(); + + assert_eq!(result.graph().elements().len(), 1); + assert_eq!(result.graph().elements()[0].name(), "Good"); + assert_eq!(result.warnings().len(), 1); +} + +#[test] +fn infers_module_from_directory_structure() { + let files = vec![ + SourceFile::new( + FilePath::new("/project/src/orders/service.rs").unwrap(), + Language::Rust, + ), + SourceFile::new( + FilePath::new("/project/src/billing/invoice.rs").unwrap(), + Language::Rust, + ), + SourceFile::new( + FilePath::new("/project/src/lib.rs").unwrap(), + Language::Rust, + ), + ]; + + let discovery = FakeFileDiscovery::new(files); + let analyzer = FakeSourceAnalyzer::new() + .with_result( + "/project/src/orders/service.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("/project/src/orders/service.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![], + vec![], + ), + ) + .with_result( + "/project/src/billing/invoice.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "Invoice", + CodeElementKind::Struct, + FilePath::new("/project/src/billing/invoice.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![], + vec![], + ), + ) + .with_result( + "/project/src/lib.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "App", + CodeElementKind::Struct, + FilePath::new("/project/src/lib.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![], + vec![], + ), + ); + + let use_case = AnalyzeCodebase::new(discovery, analyzer); + let result = use_case + .execute(Path::new("/project"), &AnalysisConfig::default()) + .unwrap(); + + let order_svc = result + .graph() + .elements() + .iter() + .find(|e| e.name() == "OrderService") + .unwrap(); + assert_eq!(order_svc.module().unwrap().as_str(), "Orders"); + + let invoice = result + .graph() + .elements() + .iter() + .find(|e| e.name() == "Invoice") + .unwrap(); + assert_eq!(invoice.module().unwrap().as_str(), "Billing"); + + let app = result + .graph() + .elements() + .iter() + .find(|e| e.name() == "App") + .unwrap(); + assert!(app.module().is_none()); +} + +#[test] +fn infers_nested_module_from_deep_directories() { + let files = vec![SourceFile::new( + FilePath::new("/project/src/orders/models/order.rs").unwrap(), + Language::Rust, + )]; + + let discovery = FakeFileDiscovery::new(files); + let analyzer = FakeSourceAnalyzer::new().with_result( + "/project/src/orders/models/order.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("/project/src/orders/models/order.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![], + vec![], + ), + ); + + let use_case = AnalyzeCodebase::new(discovery, analyzer); + let result = use_case + .execute(Path::new("/project"), &AnalysisConfig::default()) + .unwrap(); + + let order = result + .graph() + .elements() + .iter() + .find(|e| e.name() == "Order") + .unwrap(); + assert_eq!(order.module().unwrap().as_str(), "Orders"); +} + +#[test] +fn respects_config_module_mappings() { + let files = vec![SourceFile::new( + FilePath::new("/project/src/infra/db.rs").unwrap(), + Language::Rust, + )]; + + let discovery = FakeFileDiscovery::new(files); + let analyzer = FakeSourceAnalyzer::new().with_result( + "/project/src/infra/db.rs", + AnalysisResult::new( + vec![ + CodeElement::new( + "DbPool", + CodeElementKind::Struct, + FilePath::new("/project/src/infra/db.rs").unwrap(), + 1, + ) + .unwrap(), + ], + vec![], + vec![], + ), + ); + + let mut mappings = std::collections::HashMap::new(); + mappings.insert("src/infra".to_string(), "Infrastructure".to_string()); + let config = AnalysisConfig::default().with_module_mappings(mappings); + + let use_case = AnalyzeCodebase::new(discovery, analyzer); + let result = use_case.execute(Path::new("/project"), &config).unwrap(); + + let db = result + .graph() + .elements() + .iter() + .find(|e| e.name() == "DbPool") + .unwrap(); + assert_eq!(db.module().unwrap().as_str(), "Infrastructure"); +} diff --git a/crates/application/tests/fakes/diagram_renderer.rs b/crates/application/tests/fakes/diagram_renderer.rs new file mode 100644 index 0000000..11061be --- /dev/null +++ b/crates/application/tests/fakes/diagram_renderer.rs @@ -0,0 +1,17 @@ +use archlens_domain::{CodeGraph, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer}; + +pub struct FakeDiagramRenderer; + +impl FakeDiagramRenderer { + pub fn new() -> Self { + Self + } +} + +impl DiagramRenderer for FakeDiagramRenderer { + fn render(&self, graph: &CodeGraph) -> Result { + let content = format!("graph with {} elements", graph.elements().len()); + let file = RenderedFile::new("output.mmd", &content)?; + Ok(RenderOutput::single(file)) + } +} diff --git a/crates/application/tests/fakes/file_discovery.rs b/crates/application/tests/fakes/file_discovery.rs new file mode 100644 index 0000000..a357314 --- /dev/null +++ b/crates/application/tests/fakes/file_discovery.rs @@ -0,0 +1,27 @@ +use std::path::Path; + +use archlens_domain::{AnalysisConfig, DomainError, SourceFile, ports::FileDiscovery}; + +pub struct FakeFileDiscovery { + files: Vec, +} + +impl FakeFileDiscovery { + pub fn new(files: Vec) -> Self { + Self { files } + } + + pub fn empty() -> Self { + Self { files: Vec::new() } + } +} + +impl FileDiscovery for FakeFileDiscovery { + fn discover( + &self, + _root: &Path, + _config: &AnalysisConfig, + ) -> Result, DomainError> { + Ok(self.files.clone()) + } +} diff --git a/crates/application/tests/fakes/mod.rs b/crates/application/tests/fakes/mod.rs new file mode 100644 index 0000000..b3eb355 --- /dev/null +++ b/crates/application/tests/fakes/mod.rs @@ -0,0 +1,9 @@ +mod diagram_renderer; +mod file_discovery; +mod output_writer; +mod source_analyzer; + +pub use diagram_renderer::FakeDiagramRenderer; +pub use file_discovery::FakeFileDiscovery; +pub use output_writer::FakeOutputWriter; +pub use source_analyzer::FakeSourceAnalyzer; diff --git a/crates/application/tests/fakes/output_writer.rs b/crates/application/tests/fakes/output_writer.rs new file mode 100644 index 0000000..7680a54 --- /dev/null +++ b/crates/application/tests/fakes/output_writer.rs @@ -0,0 +1,26 @@ +use std::cell::RefCell; + +use archlens_domain::{DomainError, RenderOutput, ports::OutputWriter}; + +pub struct FakeOutputWriter { + written: RefCell>, +} + +impl FakeOutputWriter { + pub fn new() -> Self { + Self { + written: RefCell::new(Vec::new()), + } + } + + pub fn written_outputs(&self) -> Vec { + self.written.borrow().clone() + } +} + +impl OutputWriter for FakeOutputWriter { + fn write(&self, output: &RenderOutput) -> Result<(), DomainError> { + self.written.borrow_mut().push(output.clone()); + Ok(()) + } +} diff --git a/crates/application/tests/fakes/source_analyzer.rs b/crates/application/tests/fakes/source_analyzer.rs new file mode 100644 index 0000000..e61b198 --- /dev/null +++ b/crates/application/tests/fakes/source_analyzer.rs @@ -0,0 +1,43 @@ +use std::collections::HashMap; + +use archlens_domain::{AnalysisResult, DomainError, SourceFile, ports::SourceAnalyzer}; + +enum FakeResponse { + Success(AnalysisResult), + Failure(DomainError), +} + +pub struct FakeSourceAnalyzer { + results: HashMap, +} + +impl FakeSourceAnalyzer { + pub fn new() -> Self { + Self { + results: HashMap::new(), + } + } + + pub fn with_result(mut self, file_path: &str, result: AnalysisResult) -> Self { + self.results + .insert(file_path.to_string(), FakeResponse::Success(result)); + self + } + + pub fn with_error(mut self, file_path: &str, error: DomainError) -> Self { + self.results + .insert(file_path.to_string(), FakeResponse::Failure(error)); + self + } +} + +impl SourceAnalyzer for FakeSourceAnalyzer { + fn analyze_file(&self, file: &SourceFile) -> Result { + let key = file.path().as_str().to_string(); + match self.results.get(&key) { + Some(FakeResponse::Success(result)) => Ok(result.clone()), + Some(FakeResponse::Failure(_)) => Err(DomainError::AnalysisError(key)), + None => Ok(AnalysisResult::empty()), + } + } +} diff --git a/crates/application/tests/render_diagrams_tests.rs b/crates/application/tests/render_diagrams_tests.rs new file mode 100644 index 0000000..0d9accc --- /dev/null +++ b/crates/application/tests/render_diagrams_tests.rs @@ -0,0 +1,78 @@ +mod fakes; + +use archlens_application::queries::RenderDiagrams; +use archlens_domain::{ + CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, OutputConfig, +}; + +use fakes::{FakeDiagramRenderer, FakeOutputWriter}; + +fn build_graph() -> CodeGraph { + let mut graph = CodeGraph::new(); + graph.add_element( + CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/service.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Orders").unwrap()), + ); + graph.add_element( + CodeElement::new( + "BillingService", + CodeElementKind::Class, + FilePath::new("src/billing.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(ModuleName::new("Billing").unwrap()), + ); + graph +} + +#[test] +fn renders_single_diagram_and_writes_output() { + let renderer = FakeDiagramRenderer::new(); + let writer = FakeOutputWriter::new(); + let config = OutputConfig::default(); + + let use_case = RenderDiagrams::new(renderer, writer); + use_case.execute(&build_graph(), &config).unwrap(); + + let outputs = use_case.writer().written_outputs(); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].files().len(), 1); +} + +#[test] +fn split_mode_renders_overview_plus_per_module_diagrams() { + let renderer = FakeDiagramRenderer::new(); + let writer = FakeOutputWriter::new(); + let config = OutputConfig::default().with_split_by_module(true); + + let graph = build_graph(); // has 2 modules: Orders, Billing + + let use_case = RenderDiagrams::new(renderer, writer); + use_case.execute(&graph, &config).unwrap(); + + let outputs = use_case.writer().written_outputs(); + // 1 overview + 2 module diagrams = 3 writes + assert_eq!(outputs.len(), 3); +} + +#[test] +fn empty_graph_still_produces_output() { + let renderer = FakeDiagramRenderer::new(); + let writer = FakeOutputWriter::new(); + let config = OutputConfig::default(); + + let graph = CodeGraph::new(); + + let use_case = RenderDiagrams::new(renderer, writer); + use_case.execute(&graph, &config).unwrap(); + + let outputs = use_case.writer().written_outputs(); + assert_eq!(outputs.len(), 1); +} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml new file mode 100644 index 0000000..6b6da7f --- /dev/null +++ b/crates/domain/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "archlens-domain" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +thiserror.workspace = true diff --git a/crates/domain/src/aggregates/code_graph.rs b/crates/domain/src/aggregates/code_graph.rs new file mode 100644 index 0000000..90bc5f1 --- /dev/null +++ b/crates/domain/src/aggregates/code_graph.rs @@ -0,0 +1,78 @@ +use std::collections::HashSet; + +use crate::{CodeElement, ModuleName, Relationship}; + +#[derive(Debug, Clone)] +pub struct CodeGraph { + elements: Vec, + relationships: Vec, +} + +impl Default for CodeGraph { + fn default() -> Self { + Self::new() + } +} + +impl CodeGraph { + pub fn new() -> Self { + Self { + elements: Vec::new(), + relationships: Vec::new(), + } + } + + pub fn add_element(&mut self, element: CodeElement) { + self.elements.push(element); + } + + pub fn add_relationship(&mut self, relationship: Relationship) { + self.relationships.push(relationship); + } + + pub fn elements(&self) -> &[CodeElement] { + &self.elements + } + + pub fn relationships(&self) -> &[Relationship] { + &self.relationships + } + + pub fn modules(&self) -> Vec { + let mut seen = HashSet::new(); + let mut modules = Vec::new(); + + for element in &self.elements { + if let Some(module) = element.module() + && seen.insert(module.as_str().to_string()) + { + modules.push(module.clone()); + } + } + + modules + } + + pub fn subgraph_by_module(&self, module: &ModuleName) -> CodeGraph { + let filtered_elements: Vec = self + .elements + .iter() + .filter(|e| e.module().is_some_and(|m| m == module)) + .cloned() + .collect(); + + let element_names: HashSet<&str> = filtered_elements.iter().map(|e| e.name()).collect(); + + let filtered_relationships: Vec = self + .relationships + .iter() + .filter(|r| element_names.contains(r.source()) && element_names.contains(r.target())) + .cloned() + .collect(); + + CodeGraph { + elements: filtered_elements, + relationships: filtered_relationships, + } + } +} diff --git a/crates/domain/src/aggregates/mod.rs b/crates/domain/src/aggregates/mod.rs new file mode 100644 index 0000000..b048f79 --- /dev/null +++ b/crates/domain/src/aggregates/mod.rs @@ -0,0 +1,3 @@ +mod code_graph; + +pub use code_graph::CodeGraph; diff --git a/crates/domain/src/entities/code_element.rs b/crates/domain/src/entities/code_element.rs new file mode 100644 index 0000000..abfae2a --- /dev/null +++ b/crates/domain/src/entities/code_element.rs @@ -0,0 +1,111 @@ +use crate::{CodeElementKind, DomainError, FilePath, ModuleName, Visibility}; + +#[derive(Debug, Clone)] +pub struct CodeElement { + name: String, + kind: CodeElementKind, + file_path: FilePath, + line: usize, + visibility: Visibility, + module: Option, + generics: Vec, + attributes: Vec, + fields: Vec, + methods: Vec, +} + +impl CodeElement { + pub fn new( + name: &str, + kind: CodeElementKind, + file_path: FilePath, + line: usize, + ) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(DomainError::EmptyValue("CodeElement name")); + } + Ok(Self { + name: trimmed.to_string(), + kind, + file_path, + line, + visibility: Visibility::Public, + module: None, + generics: Vec::new(), + attributes: Vec::new(), + fields: Vec::new(), + methods: Vec::new(), + }) + } + + pub fn with_visibility(mut self, visibility: Visibility) -> Self { + self.visibility = visibility; + self + } + + pub fn with_module(mut self, module: ModuleName) -> Self { + self.module = Some(module); + self + } + + pub fn with_generics(mut self, generics: Vec) -> Self { + self.generics = generics; + self + } + + pub fn with_attributes(mut self, attributes: Vec) -> Self { + self.attributes = attributes; + self + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn kind(&self) -> CodeElementKind { + self.kind + } + + pub fn file_path(&self) -> &FilePath { + &self.file_path + } + + pub fn line(&self) -> usize { + self.line + } + + pub fn visibility(&self) -> Visibility { + self.visibility + } + + pub fn module(&self) -> Option<&ModuleName> { + self.module.as_ref() + } + + pub fn generics(&self) -> &[String] { + &self.generics + } + + pub fn attributes(&self) -> &[String] { + &self.attributes + } + + pub fn with_fields(mut self, fields: Vec) -> Self { + self.fields = fields; + self + } + + pub fn with_methods(mut self, methods: Vec) -> Self { + self.methods = methods; + self + } + + pub fn fields(&self) -> &[String] { + &self.fields + } + + pub fn methods(&self) -> &[String] { + &self.methods + } +} diff --git a/crates/domain/src/entities/mod.rs b/crates/domain/src/entities/mod.rs new file mode 100644 index 0000000..6d98832 --- /dev/null +++ b/crates/domain/src/entities/mod.rs @@ -0,0 +1,5 @@ +mod code_element; +mod relationship; + +pub use code_element::CodeElement; +pub use relationship::Relationship; diff --git a/crates/domain/src/entities/relationship.rs b/crates/domain/src/entities/relationship.rs new file mode 100644 index 0000000..6421d3d --- /dev/null +++ b/crates/domain/src/entities/relationship.rs @@ -0,0 +1,49 @@ +use crate::{DomainError, FilePath, RelationshipKind}; + +#[derive(Debug, Clone)] +pub struct Relationship { + source: String, + target: String, + kind: RelationshipKind, + source_file: Option, +} + +impl Relationship { + pub fn new(source: &str, target: &str, kind: RelationshipKind) -> Result { + let source = source.trim(); + let target = target.trim(); + if source.is_empty() { + return Err(DomainError::EmptyValue("Relationship source")); + } + if target.is_empty() { + return Err(DomainError::EmptyValue("Relationship target")); + } + Ok(Self { + source: source.to_string(), + target: target.to_string(), + kind, + source_file: None, + }) + } + + pub fn with_source_file(mut self, file: FilePath) -> Self { + self.source_file = Some(file); + self + } + + pub fn source(&self) -> &str { + &self.source + } + + pub fn target(&self) -> &str { + &self.target + } + + pub fn kind(&self) -> RelationshipKind { + self.kind + } + + pub fn source_file(&self) -> Option<&FilePath> { + self.source_file.as_ref() + } +} diff --git a/crates/domain/src/error.rs b/crates/domain/src/error.rs new file mode 100644 index 0000000..122534f --- /dev/null +++ b/crates/domain/src/error.rs @@ -0,0 +1,14 @@ +#[derive(Debug, thiserror::Error)] +pub enum DomainError { + #[error("{0} cannot be empty")] + EmptyValue(&'static str), + + #[error("failed to analyze: {0}")] + AnalysisError(String), + + #[error("IO error: {0}")] + IoError(String), + + #[error("config error: {0}")] + ConfigError(String), +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs new file mode 100644 index 0000000..2f74a34 --- /dev/null +++ b/crates/domain/src/lib.rs @@ -0,0 +1,14 @@ +mod error; + +pub mod aggregates; +pub mod entities; +pub mod ports; +pub mod value_objects; + +pub use aggregates::CodeGraph; +pub use entities::{CodeElement, Relationship}; +pub use error::DomainError; +pub use value_objects::analysis::{AnalysisConfig, AnalysisResult, AnalysisWarning}; +pub use value_objects::graph::{CodeElementKind, RelationshipKind, Visibility}; +pub use value_objects::output::{DiagramLevel, OutputConfig, RenderOutput, RenderedFile}; +pub use value_objects::source::{FilePath, Language, ModuleName, SourceFile}; diff --git a/crates/domain/src/ports/config_loader.rs b/crates/domain/src/ports/config_loader.rs new file mode 100644 index 0000000..55820b8 --- /dev/null +++ b/crates/domain/src/ports/config_loader.rs @@ -0,0 +1,6 @@ +use crate::{AnalysisConfig, DomainError, OutputConfig}; + +pub trait ConfigLoader { + fn load_analysis_config(&self) -> Result; + fn load_output_config(&self) -> Result; +} diff --git a/crates/domain/src/ports/diagram_renderer.rs b/crates/domain/src/ports/diagram_renderer.rs new file mode 100644 index 0000000..2df6dec --- /dev/null +++ b/crates/domain/src/ports/diagram_renderer.rs @@ -0,0 +1,5 @@ +use crate::{CodeGraph, DomainError, RenderOutput}; + +pub trait DiagramRenderer { + fn render(&self, graph: &CodeGraph) -> Result; +} diff --git a/crates/domain/src/ports/file_discovery.rs b/crates/domain/src/ports/file_discovery.rs new file mode 100644 index 0000000..4c0d245 --- /dev/null +++ b/crates/domain/src/ports/file_discovery.rs @@ -0,0 +1,9 @@ +use crate::{AnalysisConfig, DomainError, SourceFile}; + +pub trait FileDiscovery { + fn discover( + &self, + root: &std::path::Path, + config: &AnalysisConfig, + ) -> Result, DomainError>; +} diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs new file mode 100644 index 0000000..da02252 --- /dev/null +++ b/crates/domain/src/ports/mod.rs @@ -0,0 +1,13 @@ +mod config_loader; +mod diagram_renderer; +mod file_discovery; +mod output_writer; +mod project_analyzer; +mod source_analyzer; + +pub use config_loader::ConfigLoader; +pub use diagram_renderer::DiagramRenderer; +pub use file_discovery::FileDiscovery; +pub use output_writer::OutputWriter; +pub use project_analyzer::ProjectAnalyzer; +pub use source_analyzer::SourceAnalyzer; diff --git a/crates/domain/src/ports/output_writer.rs b/crates/domain/src/ports/output_writer.rs new file mode 100644 index 0000000..5a4505b --- /dev/null +++ b/crates/domain/src/ports/output_writer.rs @@ -0,0 +1,5 @@ +use crate::{DomainError, RenderOutput}; + +pub trait OutputWriter { + fn write(&self, output: &RenderOutput) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/ports/project_analyzer.rs b/crates/domain/src/ports/project_analyzer.rs new file mode 100644 index 0000000..b708cd9 --- /dev/null +++ b/crates/domain/src/ports/project_analyzer.rs @@ -0,0 +1,7 @@ +use std::path::Path; + +use crate::{CodeGraph, DomainError}; + +pub trait ProjectAnalyzer { + fn analyze(&self, root: &Path) -> Result; +} diff --git a/crates/domain/src/ports/source_analyzer.rs b/crates/domain/src/ports/source_analyzer.rs new file mode 100644 index 0000000..28a0dcb --- /dev/null +++ b/crates/domain/src/ports/source_analyzer.rs @@ -0,0 +1,5 @@ +use crate::{AnalysisResult, DomainError, SourceFile}; + +pub trait SourceAnalyzer: Send + Sync { + fn analyze_file(&self, file: &SourceFile) -> Result; +} diff --git a/crates/domain/src/value_objects/analysis/analysis_config.rs b/crates/domain/src/value_objects/analysis/analysis_config.rs new file mode 100644 index 0000000..9d35769 --- /dev/null +++ b/crates/domain/src/value_objects/analysis/analysis_config.rs @@ -0,0 +1,60 @@ +use std::collections::HashMap; + +use crate::DiagramLevel; + +#[derive(Debug, Clone)] +pub struct AnalysisConfig { + excludes: Vec, + level: DiagramLevel, + module_mappings: HashMap, + scope: Option, +} + +impl AnalysisConfig { + pub fn with_excludes(mut self, excludes: Vec) -> Self { + self.excludes = excludes; + self + } + + pub fn with_level(mut self, level: DiagramLevel) -> Self { + self.level = level; + self + } + + pub fn with_module_mappings(mut self, mappings: HashMap) -> Self { + self.module_mappings = mappings; + self + } + + pub fn excludes(&self) -> &[String] { + &self.excludes + } + + pub fn level(&self) -> DiagramLevel { + self.level + } + + pub fn with_scope(mut self, scope: String) -> Self { + self.scope = Some(scope); + self + } + + pub fn module_mappings(&self) -> &HashMap { + &self.module_mappings + } + + pub fn scope(&self) -> Option<&str> { + self.scope.as_deref() + } +} + +impl Default for AnalysisConfig { + fn default() -> Self { + Self { + excludes: Vec::new(), + level: DiagramLevel::Module, + module_mappings: HashMap::new(), + scope: None, + } + } +} diff --git a/crates/domain/src/value_objects/analysis/analysis_result.rs b/crates/domain/src/value_objects/analysis/analysis_result.rs new file mode 100644 index 0000000..8c52023 --- /dev/null +++ b/crates/domain/src/value_objects/analysis/analysis_result.rs @@ -0,0 +1,42 @@ +use crate::{AnalysisWarning, CodeElement, Relationship}; + +#[derive(Debug, Clone)] +pub struct AnalysisResult { + elements: Vec, + relationships: Vec, + warnings: Vec, +} + +impl AnalysisResult { + pub fn new( + elements: Vec, + relationships: Vec, + warnings: Vec, + ) -> Self { + Self { + elements, + relationships, + warnings, + } + } + + pub fn empty() -> Self { + Self { + elements: Vec::new(), + relationships: Vec::new(), + warnings: Vec::new(), + } + } + + pub fn elements(&self) -> &[CodeElement] { + &self.elements + } + + pub fn relationships(&self) -> &[Relationship] { + &self.relationships + } + + pub fn warnings(&self) -> &[AnalysisWarning] { + &self.warnings + } +} diff --git a/crates/domain/src/value_objects/analysis/analysis_warning.rs b/crates/domain/src/value_objects/analysis/analysis_warning.rs new file mode 100644 index 0000000..6d4a5a9 --- /dev/null +++ b/crates/domain/src/value_objects/analysis/analysis_warning.rs @@ -0,0 +1,34 @@ +use crate::{DomainError, FilePath}; + +#[derive(Debug, Clone)] +pub struct AnalysisWarning { + file_path: FilePath, + line: usize, + message: String, +} + +impl AnalysisWarning { + pub fn new(file_path: FilePath, line: usize, message: &str) -> Result { + let message = message.trim(); + if message.is_empty() { + return Err(DomainError::EmptyValue("AnalysisWarning message")); + } + Ok(Self { + file_path, + line, + message: message.to_string(), + }) + } + + pub fn file_path(&self) -> &FilePath { + &self.file_path + } + + pub fn line(&self) -> usize { + self.line + } + + pub fn message(&self) -> &str { + &self.message + } +} diff --git a/crates/domain/src/value_objects/analysis/mod.rs b/crates/domain/src/value_objects/analysis/mod.rs new file mode 100644 index 0000000..411b22d --- /dev/null +++ b/crates/domain/src/value_objects/analysis/mod.rs @@ -0,0 +1,7 @@ +mod analysis_config; +mod analysis_result; +mod analysis_warning; + +pub use analysis_config::AnalysisConfig; +pub use analysis_result::AnalysisResult; +pub use analysis_warning::AnalysisWarning; diff --git a/crates/domain/src/value_objects/graph/code_element_kind.rs b/crates/domain/src/value_objects/graph/code_element_kind.rs new file mode 100644 index 0000000..5716941 --- /dev/null +++ b/crates/domain/src/value_objects/graph/code_element_kind.rs @@ -0,0 +1,9 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CodeElementKind { + Class, + Struct, + Trait, + Interface, + Enum, + Project, +} diff --git a/crates/domain/src/value_objects/graph/mod.rs b/crates/domain/src/value_objects/graph/mod.rs new file mode 100644 index 0000000..459dabb --- /dev/null +++ b/crates/domain/src/value_objects/graph/mod.rs @@ -0,0 +1,7 @@ +mod code_element_kind; +mod relationship_kind; +mod visibility; + +pub use code_element_kind::CodeElementKind; +pub use relationship_kind::RelationshipKind; +pub use visibility::Visibility; diff --git a/crates/domain/src/value_objects/graph/relationship_kind.rs b/crates/domain/src/value_objects/graph/relationship_kind.rs new file mode 100644 index 0000000..4436305 --- /dev/null +++ b/crates/domain/src/value_objects/graph/relationship_kind.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RelationshipKind { + Inheritance, + Composition, + Import, +} diff --git a/crates/domain/src/value_objects/graph/visibility.rs b/crates/domain/src/value_objects/graph/visibility.rs new file mode 100644 index 0000000..686304e --- /dev/null +++ b/crates/domain/src/value_objects/graph/visibility.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Visibility { + Public, + Private, + Internal, +} diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs new file mode 100644 index 0000000..3d3cc00 --- /dev/null +++ b/crates/domain/src/value_objects/mod.rs @@ -0,0 +1,4 @@ +pub mod analysis; +pub mod graph; +pub mod output; +pub mod source; diff --git a/crates/domain/src/value_objects/output/diagram_level.rs b/crates/domain/src/value_objects/output/diagram_level.rs new file mode 100644 index 0000000..cb092cc --- /dev/null +++ b/crates/domain/src/value_objects/output/diagram_level.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DiagramLevel { + Project, + Module, + Type, +} diff --git a/crates/domain/src/value_objects/output/mod.rs b/crates/domain/src/value_objects/output/mod.rs new file mode 100644 index 0000000..48f2ec3 --- /dev/null +++ b/crates/domain/src/value_objects/output/mod.rs @@ -0,0 +1,9 @@ +mod diagram_level; +mod output_config; +mod render_output; +mod rendered_file; + +pub use diagram_level::DiagramLevel; +pub use output_config::OutputConfig; +pub use render_output::RenderOutput; +pub use rendered_file::RenderedFile; diff --git a/crates/domain/src/value_objects/output/output_config.rs b/crates/domain/src/value_objects/output/output_config.rs new file mode 100644 index 0000000..e9f91e9 --- /dev/null +++ b/crates/domain/src/value_objects/output/output_config.rs @@ -0,0 +1,25 @@ +#[derive(Debug, Clone, Default)] +pub struct OutputConfig { + split_by_module: bool, + output_path: Option, +} + +impl OutputConfig { + pub fn with_split_by_module(mut self, split: bool) -> Self { + self.split_by_module = split; + self + } + + pub fn with_output_path(mut self, path: String) -> Self { + self.output_path = Some(path); + self + } + + pub fn split_by_module(&self) -> bool { + self.split_by_module + } + + pub fn output_path(&self) -> Option<&str> { + self.output_path.as_deref() + } +} diff --git a/crates/domain/src/value_objects/output/render_output.rs b/crates/domain/src/value_objects/output/render_output.rs new file mode 100644 index 0000000..4261f9d --- /dev/null +++ b/crates/domain/src/value_objects/output/render_output.rs @@ -0,0 +1,20 @@ +use crate::RenderedFile; + +#[derive(Debug, Clone)] +pub struct RenderOutput { + files: Vec, +} + +impl RenderOutput { + pub fn new(files: Vec) -> Self { + Self { files } + } + + pub fn single(file: RenderedFile) -> Self { + Self { files: vec![file] } + } + + pub fn files(&self) -> &[RenderedFile] { + &self.files + } +} diff --git a/crates/domain/src/value_objects/output/rendered_file.rs b/crates/domain/src/value_objects/output/rendered_file.rs new file mode 100644 index 0000000..d7ea46e --- /dev/null +++ b/crates/domain/src/value_objects/output/rendered_file.rs @@ -0,0 +1,32 @@ +use crate::DomainError; + +#[derive(Debug, Clone)] +pub struct RenderedFile { + name: String, + content: String, +} + +impl RenderedFile { + pub fn new(name: &str, content: &str) -> Result { + let name = name.trim(); + let content = content.trim(); + if name.is_empty() { + return Err(DomainError::EmptyValue("RenderedFile name")); + } + if content.is_empty() { + return Err(DomainError::EmptyValue("RenderedFile content")); + } + Ok(Self { + name: name.to_string(), + content: content.to_string(), + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn content(&self) -> &str { + &self.content + } +} diff --git a/crates/domain/src/value_objects/source/file_path.rs b/crates/domain/src/value_objects/source/file_path.rs new file mode 100644 index 0000000..4cf7212 --- /dev/null +++ b/crates/domain/src/value_objects/source/file_path.rs @@ -0,0 +1,18 @@ +use crate::DomainError; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct FilePath(String); + +impl FilePath { + pub fn new(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(DomainError::EmptyValue("FilePath")); + } + Ok(Self(trimmed.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/crates/domain/src/value_objects/source/language.rs b/crates/domain/src/value_objects/source/language.rs new file mode 100644 index 0000000..0f06117 --- /dev/null +++ b/crates/domain/src/value_objects/source/language.rs @@ -0,0 +1,16 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Language { + Rust, + CSharp, + Python, +} + +impl Language { + pub fn name(&self) -> &'static str { + match self { + Self::Rust => "Rust", + Self::CSharp => "CSharp", + Self::Python => "Python", + } + } +} diff --git a/crates/domain/src/value_objects/source/mod.rs b/crates/domain/src/value_objects/source/mod.rs new file mode 100644 index 0000000..1d35419 --- /dev/null +++ b/crates/domain/src/value_objects/source/mod.rs @@ -0,0 +1,9 @@ +mod file_path; +mod language; +mod module_name; +mod source_file; + +pub use file_path::FilePath; +pub use language::Language; +pub use module_name::ModuleName; +pub use source_file::SourceFile; diff --git a/crates/domain/src/value_objects/source/module_name.rs b/crates/domain/src/value_objects/source/module_name.rs new file mode 100644 index 0000000..21ba2ce --- /dev/null +++ b/crates/domain/src/value_objects/source/module_name.rs @@ -0,0 +1,18 @@ +use crate::DomainError; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ModuleName(String); + +impl ModuleName { + pub fn new(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(DomainError::EmptyValue("ModuleName")); + } + Ok(Self(trimmed.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/crates/domain/src/value_objects/source/source_file.rs b/crates/domain/src/value_objects/source/source_file.rs new file mode 100644 index 0000000..85f1217 --- /dev/null +++ b/crates/domain/src/value_objects/source/source_file.rs @@ -0,0 +1,21 @@ +use crate::{FilePath, Language}; + +#[derive(Debug, Clone)] +pub struct SourceFile { + path: FilePath, + language: Language, +} + +impl SourceFile { + pub fn new(path: FilePath, language: Language) -> Self { + Self { path, language } + } + + pub fn path(&self) -> &FilePath { + &self.path + } + + pub fn language(&self) -> Language { + self.language + } +} diff --git a/crates/domain/tests/analysis_result_tests.rs b/crates/domain/tests/analysis_result_tests.rs new file mode 100644 index 0000000..bd08f9b --- /dev/null +++ b/crates/domain/tests/analysis_result_tests.rs @@ -0,0 +1,39 @@ +use archlens_domain::{ + AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, FilePath, Relationship, + RelationshipKind, +}; + +#[test] +fn analysis_result_collects_elements_relationships_and_warnings() { + let element = CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/order.rs").unwrap(), + 1, + ) + .unwrap(); + + let relationship = + Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(); + + let warning = AnalysisWarning::new( + FilePath::new("src/broken.rs").unwrap(), + 10, + "unparseable macro", + ) + .unwrap(); + + let result = AnalysisResult::new(vec![element], vec![relationship], vec![warning]); + + assert_eq!(result.elements().len(), 1); + assert_eq!(result.relationships().len(), 1); + assert_eq!(result.warnings().len(), 1); +} + +#[test] +fn empty_analysis_result() { + let result = AnalysisResult::empty(); + assert!(result.elements().is_empty()); + assert!(result.relationships().is_empty()); + assert!(result.warnings().is_empty()); +} diff --git a/crates/domain/tests/analysis_warning_tests.rs b/crates/domain/tests/analysis_warning_tests.rs new file mode 100644 index 0000000..9066075 --- /dev/null +++ b/crates/domain/tests/analysis_warning_tests.rs @@ -0,0 +1,21 @@ +use archlens_domain::{AnalysisWarning, FilePath}; + +#[test] +fn warning_carries_location_and_message() { + let warning = AnalysisWarning::new( + FilePath::new("src/broken.rs").unwrap(), + 42, + "could not parse struct definition", + ) + .unwrap(); + + assert_eq!(warning.file_path().as_str(), "src/broken.rs"); + assert_eq!(warning.line(), 42); + assert_eq!(warning.message(), "could not parse struct definition"); +} + +#[test] +fn warning_rejects_empty_message() { + let result = AnalysisWarning::new(FilePath::new("src/broken.rs").unwrap(), 1, ""); + assert!(result.is_err()); +} diff --git a/crates/domain/tests/code_element_tests.rs b/crates/domain/tests/code_element_tests.rs new file mode 100644 index 0000000..1624a0e --- /dev/null +++ b/crates/domain/tests/code_element_tests.rs @@ -0,0 +1,107 @@ +use archlens_domain::{CodeElement, CodeElementKind, FilePath, ModuleName, Visibility}; + +#[test] +fn code_element_is_created_with_required_fields() { + let element = CodeElement::new( + "OrderService", + CodeElementKind::Class, + FilePath::new("src/orders/service.rs").unwrap(), + 42, + ) + .unwrap(); + + assert_eq!(element.name(), "OrderService"); + assert_eq!(element.kind(), CodeElementKind::Class); + assert_eq!(element.file_path().as_str(), "src/orders/service.rs"); + assert_eq!(element.line(), 42); +} + +#[test] +fn code_element_with_empty_name_is_rejected() { + let result = CodeElement::new( + "", + CodeElementKind::Class, + FilePath::new("src/main.rs").unwrap(), + 1, + ); + assert!(result.is_err()); +} + +#[test] +fn code_element_defaults_to_public_visibility() { + let element = CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/order.rs").unwrap(), + 1, + ) + .unwrap(); + + assert_eq!(element.visibility(), Visibility::Public); +} + +#[test] +fn code_element_with_visibility() { + let element = CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/order.rs").unwrap(), + 1, + ) + .unwrap() + .with_visibility(Visibility::Private); + + assert_eq!(element.visibility(), Visibility::Private); +} + +#[test] +fn code_element_with_module_path() { + let module = ModuleName::new("Orders").unwrap(); + let element = CodeElement::new( + "Order", + CodeElementKind::Struct, + FilePath::new("src/orders/order.rs").unwrap(), + 1, + ) + .unwrap() + .with_module(module.clone()); + + assert_eq!(element.module(), Some(&module)); +} + +#[test] +fn code_element_with_generics() { + let element = CodeElement::new( + "Repository", + CodeElementKind::Trait, + FilePath::new("src/repo.rs").unwrap(), + 1, + ) + .unwrap() + .with_generics(vec!["T".to_string()]); + + assert_eq!(element.generics(), &["T"]); +} + +#[test] +fn code_element_with_attributes() { + let element = CodeElement::new( + "OrderController", + CodeElementKind::Class, + FilePath::new("src/controller.cs").unwrap(), + 1, + ) + .unwrap() + .with_attributes(vec!["ApiController".to_string()]); + + assert_eq!(element.attributes(), &["ApiController"]); +} + +#[test] +fn all_element_kinds_exist() { + let _class = CodeElementKind::Class; + let _struct = CodeElementKind::Struct; + let _trait = CodeElementKind::Trait; + let _interface = CodeElementKind::Interface; + let _enum = CodeElementKind::Enum; +} diff --git a/crates/domain/tests/code_graph_tests.rs b/crates/domain/tests/code_graph_tests.rs new file mode 100644 index 0000000..64cc35c --- /dev/null +++ b/crates/domain/tests/code_graph_tests.rs @@ -0,0 +1,130 @@ +use archlens_domain::{ + CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, Relationship, RelationshipKind, +}; + +fn make_element(name: &str, module: Option<&str>) -> CodeElement { + let mut element = CodeElement::new( + name, + CodeElementKind::Class, + FilePath::new(&format!("src/{name}.rs")).unwrap(), + 1, + ) + .unwrap(); + + if let Some(m) = module { + element = element.with_module(ModuleName::new(m).unwrap()); + } + + element +} + +#[test] +fn empty_graph_has_no_elements() { + let graph = CodeGraph::new(); + assert!(graph.elements().is_empty()); + assert!(graph.relationships().is_empty()); +} + +#[test] +fn graph_stores_added_elements() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("OrderService", None)); + graph.add_element(make_element("Order", None)); + + assert_eq!(graph.elements().len(), 2); +} + +#[test] +fn graph_stores_relationships() { + let mut graph = CodeGraph::new(); + let service = make_element("OrderService", None); + let repo = make_element("OrderRepository", None); + + graph.add_element(service); + graph.add_element(repo); + graph.add_relationship( + Relationship::new( + "OrderService", + "OrderRepository", + RelationshipKind::Composition, + ) + .unwrap(), + ); + + assert_eq!(graph.relationships().len(), 1); + let rel = &graph.relationships()[0]; + assert_eq!(rel.source(), "OrderService"); + assert_eq!(rel.target(), "OrderRepository"); + assert_eq!(rel.kind(), RelationshipKind::Composition); +} + +#[test] +fn subgraph_by_module_filters_elements() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("OrderService", Some("Orders"))); + graph.add_element(make_element("Order", Some("Orders"))); + graph.add_element(make_element("BillingService", Some("Billing"))); + + let module = ModuleName::new("Orders").unwrap(); + let subgraph = graph.subgraph_by_module(&module); + + assert_eq!(subgraph.elements().len(), 2); + assert!( + subgraph + .elements() + .iter() + .all(|e| e.module().unwrap().as_str() == "Orders") + ); +} + +#[test] +fn subgraph_includes_relationships_within_module() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("OrderService", Some("Orders"))); + graph.add_element(make_element("Order", Some("Orders"))); + graph.add_element(make_element("BillingService", Some("Billing"))); + + graph.add_relationship( + Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(), + ); + graph.add_relationship( + Relationship::new( + "OrderService", + "BillingService", + RelationshipKind::Composition, + ) + .unwrap(), + ); + + let module = ModuleName::new("Orders").unwrap(); + let subgraph = graph.subgraph_by_module(&module); + + assert_eq!(subgraph.relationships().len(), 1); + assert_eq!(subgraph.relationships()[0].target(), "Order"); +} + +#[test] +fn subgraph_of_nonexistent_module_is_empty() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("OrderService", Some("Orders"))); + + let module = ModuleName::new("Unknown").unwrap(); + let subgraph = graph.subgraph_by_module(&module); + + assert!(subgraph.elements().is_empty()); + assert!(subgraph.relationships().is_empty()); +} + +#[test] +fn graph_lists_unique_modules() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("OrderService", Some("Orders"))); + graph.add_element(make_element("Order", Some("Orders"))); + graph.add_element(make_element("BillingService", Some("Billing"))); + graph.add_element(make_element("Orphan", None)); + + let modules = graph.modules(); + assert_eq!(modules.len(), 2); + assert!(modules.iter().any(|m| m.as_str() == "Orders")); + assert!(modules.iter().any(|m| m.as_str() == "Billing")); +} diff --git a/crates/domain/tests/config_tests.rs b/crates/domain/tests/config_tests.rs new file mode 100644 index 0000000..1476317 --- /dev/null +++ b/crates/domain/tests/config_tests.rs @@ -0,0 +1,37 @@ +use archlens_domain::{AnalysisConfig, DiagramLevel, OutputConfig}; + +#[test] +fn analysis_config_has_sensible_defaults() { + let config = AnalysisConfig::default(); + assert!(config.excludes().is_empty()); + assert_eq!(config.level(), DiagramLevel::Module); + assert!(config.module_mappings().is_empty()); +} + +#[test] +fn analysis_config_with_excludes() { + let config = + AnalysisConfig::default().with_excludes(vec!["tests/".to_string(), "vendor/".to_string()]); + + assert_eq!(config.excludes().len(), 2); +} + +#[test] +fn output_config_has_sensible_defaults() { + let config = OutputConfig::default(); + assert!(!config.split_by_module()); + assert!(config.output_path().is_none()); +} + +#[test] +fn output_config_with_split() { + let config = OutputConfig::default().with_split_by_module(true); + assert!(config.split_by_module()); +} + +#[test] +fn all_diagram_levels_exist() { + let _project = DiagramLevel::Project; + let _module = DiagramLevel::Module; + let _type_level = DiagramLevel::Type; +} diff --git a/crates/domain/tests/file_path_tests.rs b/crates/domain/tests/file_path_tests.rs new file mode 100644 index 0000000..850c77e --- /dev/null +++ b/crates/domain/tests/file_path_tests.rs @@ -0,0 +1,29 @@ +use archlens_domain::FilePath; + +#[test] +fn valid_file_path_is_created() { + let path = FilePath::new("src/main.rs").unwrap(); + assert_eq!(path.as_str(), "src/main.rs"); +} + +#[test] +fn empty_file_path_is_rejected() { + let result = FilePath::new(""); + assert!(result.is_err()); +} + +#[test] +fn whitespace_only_file_path_is_rejected() { + let result = FilePath::new(" "); + assert!(result.is_err()); +} + +#[test] +fn file_paths_are_comparable() { + let a = FilePath::new("src/main.rs").unwrap(); + let b = FilePath::new("src/main.rs").unwrap(); + let c = FilePath::new("src/lib.rs").unwrap(); + + assert_eq!(a, b); + assert_ne!(a, c); +} diff --git a/crates/domain/tests/language_tests.rs b/crates/domain/tests/language_tests.rs new file mode 100644 index 0000000..f3bb412 --- /dev/null +++ b/crates/domain/tests/language_tests.rs @@ -0,0 +1,18 @@ +use archlens_domain::Language; + +#[test] +fn known_languages_are_available() { + let rust = Language::Rust; + let csharp = Language::CSharp; + let python = Language::Python; + + assert_eq!(rust.name(), "Rust"); + assert_eq!(csharp.name(), "CSharp"); + assert_eq!(python.name(), "Python"); +} + +#[test] +fn languages_are_comparable() { + assert_eq!(Language::Rust, Language::Rust); + assert_ne!(Language::Rust, Language::Python); +} diff --git a/crates/domain/tests/module_name_tests.rs b/crates/domain/tests/module_name_tests.rs new file mode 100644 index 0000000..80ac6ec --- /dev/null +++ b/crates/domain/tests/module_name_tests.rs @@ -0,0 +1,23 @@ +use archlens_domain::ModuleName; + +#[test] +fn valid_module_name_is_created() { + let name = ModuleName::new("Orders").unwrap(); + assert_eq!(name.as_str(), "Orders"); +} + +#[test] +fn empty_module_name_is_rejected() { + let result = ModuleName::new(""); + assert!(result.is_err()); +} + +#[test] +fn module_names_are_comparable() { + let a = ModuleName::new("Orders").unwrap(); + let b = ModuleName::new("Orders").unwrap(); + let c = ModuleName::new("Billing").unwrap(); + + assert_eq!(a, b); + assert_ne!(a, c); +} diff --git a/crates/domain/tests/render_output_tests.rs b/crates/domain/tests/render_output_tests.rs new file mode 100644 index 0000000..7a5b30f --- /dev/null +++ b/crates/domain/tests/render_output_tests.rs @@ -0,0 +1,37 @@ +use archlens_domain::{RenderOutput, RenderedFile}; + +#[test] +fn rendered_file_carries_name_and_content() { + let file = RenderedFile::new("overview.mmd", "graph TD;").unwrap(); + assert_eq!(file.name(), "overview.mmd"); + assert_eq!(file.content(), "graph TD;"); +} + +#[test] +fn rendered_file_rejects_empty_name() { + let result = RenderedFile::new("", "content"); + assert!(result.is_err()); +} + +#[test] +fn rendered_file_rejects_empty_content() { + let result = RenderedFile::new("file.mmd", ""); + assert!(result.is_err()); +} + +#[test] +fn render_output_holds_multiple_files() { + let files = vec![ + RenderedFile::new("overview.mmd", "graph TD;").unwrap(), + RenderedFile::new("orders.mmd", "classDiagram").unwrap(), + ]; + let output = RenderOutput::new(files); + assert_eq!(output.files().len(), 2); +} + +#[test] +fn render_output_can_be_single_file() { + let file = RenderedFile::new("arch.mmd", "graph TD;").unwrap(); + let output = RenderOutput::single(file); + assert_eq!(output.files().len(), 1); +} diff --git a/crates/domain/tests/source_file_tests.rs b/crates/domain/tests/source_file_tests.rs new file mode 100644 index 0000000..21011cd --- /dev/null +++ b/crates/domain/tests/source_file_tests.rs @@ -0,0 +1,10 @@ +use archlens_domain::{FilePath, Language, SourceFile}; + +#[test] +fn source_file_carries_path_and_language() { + let path = FilePath::new("src/main.rs").unwrap(); + let file = SourceFile::new(path.clone(), Language::Rust); + + assert_eq!(file.path(), &path); + assert_eq!(file.language(), Language::Rust); +} diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml new file mode 100644 index 0000000..df01a81 --- /dev/null +++ b/crates/presentation/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "archlens" +version = "0.1.0" +edition = "2024" +publish = false + +[[bin]] +name = "archlens" +path = "src/main.rs" + +[dependencies] +archlens-domain.workspace = true +archlens-application.workspace = true +archlens-tree-sitter.workspace = true +archlens-walkdir.workspace = true +archlens-mermaid.workspace = true +archlens-ascii.workspace = true +archlens-file-writer.workspace = true +archlens-stdout-writer.workspace = true +archlens-toml-config.workspace = true +archlens-cargo-workspace.workspace = true +anyhow.workspace = true +clap.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/presentation/src/cli.rs b/crates/presentation/src/cli.rs new file mode 100644 index 0000000..0b56f99 --- /dev/null +++ b/crates/presentation/src/cli.rs @@ -0,0 +1,61 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +#[derive(Parser, Debug)] +#[command( + name = "archlens", + about = "Generate architecture diagrams from source code" +)] +pub struct Cli { + #[command(subcommand)] + pub command: Option, + + #[arg(default_value = ".")] + pub path: PathBuf, + + #[arg(long, default_value = "module")] + pub level: String, + + #[arg(long, default_value = "mermaid")] + pub format: String, + + #[arg(long)] + pub output: Option, + + #[arg(long)] + pub config: Option, + + #[arg(long)] + pub scope: Option, + + #[arg(long)] + pub exclude: Vec, + + #[arg(long)] + pub split_by_module: bool, + + #[arg(long)] + pub strict: bool, + + #[arg( + long, + help = "Check if output matches existing file, exit 1 if different" + )] + pub check: bool, + + #[arg(short, long, action = clap::ArgAction::Count)] + pub verbose: u8, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + Init { + #[arg(default_value = ".")] + path: PathBuf, + }, + Diff { + #[arg(help = "Path to existing diagram file to compare against")] + existing: PathBuf, + }, +} diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs new file mode 100644 index 0000000..0ef5c8b --- /dev/null +++ b/crates/presentation/src/lib.rs @@ -0,0 +1,392 @@ +mod cli; + +use std::path::PathBuf; + +use anyhow::{Result, bail}; + +use archlens_application::queries::AnalyzeCodebase; +use archlens_ascii::AsciiRenderer; +use archlens_cargo_workspace::CargoWorkspaceAnalyzer; +use archlens_domain::{ + CodeGraph, DiagramLevel, + ports::{ConfigLoader, OutputWriter, ProjectAnalyzer}, +}; +use archlens_file_writer::FileOutputWriter; +use archlens_mermaid::MermaidRenderer; +use archlens_stdout_writer::StdoutOutputWriter; +use archlens_toml_config::TomlConfigLoader; +use archlens_tree_sitter::TreeSitterAnalyzer; +use archlens_walkdir::WalkdirDiscovery; + +pub use cli::{Cli, Command}; + +pub type CliArgs = Cli; + +pub fn run(args: Cli) -> Result<()> { + match &args.command { + Some(Command::Init { path }) => return init_config(path), + Some(Command::Diff { existing }) => return run_diff(&args, existing), + None => {} + } + init_tracing(args.verbose); + + let config_loader = match &args.config { + Some(path) => TomlConfigLoader::from_path(std::path::Path::new(path))?, + None => { + let default_path = args.path.join("archlens.toml"); + if default_path.exists() { + TomlConfigLoader::from_path(&default_path)? + } else { + TomlConfigLoader::default() + } + } + }; + + let mut analysis_config = config_loader.load_analysis_config()?; + let level = parse_level(&args.level); + analysis_config = analysis_config.with_level(level); + if let Some(ref scope) = args.scope { + analysis_config = analysis_config.with_scope(scope.clone()); + } + if !args.exclude.is_empty() { + let mut excludes = analysis_config.excludes().to_vec(); + excludes.extend(args.exclude.iter().cloned()); + analysis_config = analysis_config.with_excludes(excludes); + } + + let graph = if level == DiagramLevel::Project { + let project_analyzer = CargoWorkspaceAnalyzer::new(); + project_analyzer.analyze(&args.path)? + } else { + let discovery = WalkdirDiscovery::new(); + let analyzer = TreeSitterAnalyzer::new(); + let analyze = AnalyzeCodebase::new(discovery, analyzer); + let result = analyze.execute(&args.path, &analysis_config)?; + + if !result.warnings().is_empty() { + for warning in result.warnings() { + eprintln!( + "WARNING: {}:{} {}", + warning.file_path().as_str(), + warning.line(), + warning.message() + ); + } + if args.strict { + bail!( + "analysis produced {} warning(s) in strict mode", + result.warnings().len() + ); + } + } + + let mut graph = result.graph().clone(); + + if level == DiagramLevel::Module { + let workspace_toml = args.path.join("Cargo.toml"); + if workspace_toml.exists() + && let Ok(project_graph) = CargoWorkspaceAnalyzer::new().analyze(&args.path) + { + merge_project_deps_as_module_edges(&mut graph, &project_graph); + } + } + + graph + }; + + let renderer: Box = match &args.format[..] { + "mermaid" => Box::new(MermaidRenderer::with_level(level)), + "ascii" => Box::new(AsciiRenderer::new()), + fmt => bail!("unknown format: {fmt}"), + }; + + let ext = match &args.format[..] { + "mermaid" => "mmd", + _ => "txt", + }; + + if args.check { + if let Some(ref path) = args.output { + let output = renderer.render(&graph)?; + let current = output.files().first().map(|f| f.content()).unwrap_or(""); + let existing = std::fs::read_to_string(path).unwrap_or_default(); + if current != existing { + eprintln!("Architecture diagram is outdated: {path}"); + std::process::exit(1); + } + println!("Architecture diagram is up to date."); + return Ok(()); + } else { + bail!("--check requires --output to specify the file to check against"); + } + } + + if args.split_by_module { + write_split(&graph, &*renderer, &args.output, ext)?; + } else { + write_single(&graph, &*renderer, &args.output)?; + } + + Ok(()) +} + +fn write_split( + graph: &CodeGraph, + renderer: &dyn archlens_domain::ports::DiagramRenderer, + output: &Option, + ext: &str, +) -> Result<()> { + let output_dir = output + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let writer = FileOutputWriter::new(output_dir); + + let overview = renderer.render(graph)?; + let overview_file = archlens_domain::RenderedFile::new( + &format!("overview.{ext}"), + overview.files().first().map(|f| f.content()).unwrap_or(""), + )?; + writer.write(&archlens_domain::RenderOutput::single(overview_file))?; + + for module in graph.modules() { + let subgraph = graph.subgraph_by_module(&module); + let module_output = renderer.render(&subgraph)?; + let module_file = archlens_domain::RenderedFile::new( + &format!("{}.{ext}", module.as_str().to_lowercase()), + module_output + .files() + .first() + .map(|f| f.content()) + .unwrap_or(""), + )?; + writer.write(&archlens_domain::RenderOutput::single(module_file))?; + } + + Ok(()) +} + +fn write_single( + graph: &CodeGraph, + renderer: &dyn archlens_domain::ports::DiagramRenderer, + output: &Option, +) -> Result<()> { + let rendered = renderer.render(graph)?; + + match output { + Some(path) => { + let writer = FileOutputWriter::single_file(PathBuf::from(path)); + writer.write(&rendered)?; + } + None => { + let writer = StdoutOutputWriter::new(); + writer.write(&rendered)?; + } + } + + Ok(()) +} + +fn merge_project_deps_as_module_edges( + graph: &mut archlens_domain::CodeGraph, + project_graph: &archlens_domain::CodeGraph, +) { + use std::collections::HashMap; + + let mut crate_to_module: HashMap<&str, &str> = HashMap::new(); + for element in project_graph.elements() { + let module = element + .module() + .map(|m| m.as_str()) + .unwrap_or(element.name()); + crate_to_module.insert(element.name(), module); + } + + let graph_modules: std::collections::HashSet = graph + .modules() + .iter() + .map(|m| m.as_str().to_string()) + .collect(); + + for rel in project_graph.relationships() { + let src_module = crate_to_module.get(rel.source()); + let tgt_module = crate_to_module.get(rel.target()); + + if let (Some(src), Some(tgt)) = (src_module, tgt_module) { + let src_cap = capitalize(src); + let tgt_cap = capitalize(tgt); + + if src_cap != tgt_cap + && graph_modules.contains(&src_cap) + && graph_modules.contains(&tgt_cap) + && let Ok(edge) = archlens_domain::Relationship::new( + &src_cap, + &tgt_cap, + archlens_domain::RelationshipKind::Composition, + ) + { + graph.add_relationship(edge); + } + } + } +} + +fn capitalize(s: &str) -> String { + s.split('-') + .map(|seg| { + if seg.is_empty() { + String::new() + } else { + format!("{}{}", seg[..1].to_uppercase(), &seg[1..]) + } + }) + .collect::>() + .join("-") +} + +fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> { + init_tracing(args.verbose); + + let config_loader = match &args.config { + Some(path) => TomlConfigLoader::from_path(std::path::Path::new(path))?, + None => { + let default_path = args.path.join("archlens.toml"); + if default_path.exists() { + TomlConfigLoader::from_path(&default_path)? + } else { + TomlConfigLoader::default() + } + } + }; + + let mut analysis_config = config_loader.load_analysis_config()?; + let level = parse_level(&args.level); + analysis_config = analysis_config.with_level(level); + + let graph = if level == DiagramLevel::Project { + CargoWorkspaceAnalyzer::new().analyze(&args.path)? + } else { + let discovery = WalkdirDiscovery::new(); + let analyzer = TreeSitterAnalyzer::new(); + let analyze = AnalyzeCodebase::new(discovery, analyzer); + let result = analyze.execute(&args.path, &analysis_config)?; + let mut graph = result.graph().clone(); + if level == DiagramLevel::Module { + let workspace_toml = args.path.join("Cargo.toml"); + if workspace_toml.exists() + && let Ok(project_graph) = CargoWorkspaceAnalyzer::new().analyze(&args.path) + { + merge_project_deps_as_module_edges(&mut graph, &project_graph); + } + } + graph + }; + + let renderer: Box = match &args.format[..] { + "mermaid" => Box::new(MermaidRenderer::with_level(level)), + "ascii" => Box::new(AsciiRenderer::new()), + fmt => bail!("unknown format: {fmt}"), + }; + + let output = renderer.render(&graph)?; + let current = output.files().first().map(|f| f.content()).unwrap_or(""); + + let existing = std::fs::read_to_string(existing_path).unwrap_or_default(); + + if current == existing { + println!("No changes detected."); + return Ok(()); + } + + let current_lines: Vec<&str> = current.lines().collect(); + let existing_lines: Vec<&str> = existing.lines().collect(); + + let mut added = Vec::new(); + let mut removed = Vec::new(); + + for line in ¤t_lines { + if !existing_lines.contains(line) { + added.push(*line); + } + } + for line in &existing_lines { + if !current_lines.contains(line) { + removed.push(*line); + } + } + + if !removed.is_empty() { + println!("Removed:"); + for line in &removed { + println!(" - {line}"); + } + } + if !added.is_empty() { + println!("Added:"); + for line in &added { + println!(" + {line}"); + } + } + + println!("\n{} added, {} removed", added.len(), removed.len()); + std::process::exit(1); +} + +fn init_config(path: &std::path::Path) -> Result<()> { + let config_path = path.join("archlens.toml"); + if config_path.exists() { + bail!("archlens.toml already exists at {}", config_path.display()); + } + + let content = r#"[analysis] +# Directories to exclude from analysis +exclude = ["tests/", "vendor/", "generated/"] + +# Default granularity: "module", "type", or "project" +level = "module" + +[modules] +# Map directories to module names (overrides auto-detection) +# "src/infra" = "Infrastructure" +# "src/api" = "API" + +[output] +# Default output format +format = "mermaid" + +# Default output path (omit for stdout) +# path = "docs/architecture.mmd" + +# Generate separate files per module +split_by_module = false +"#; + + std::fs::write(&config_path, content)?; + println!("Created {}", config_path.display()); + Ok(()) +} + +fn parse_level(level: &str) -> DiagramLevel { + match level { + "type" => DiagramLevel::Type, + "project" => DiagramLevel::Project, + _ => DiagramLevel::Module, + } +} + +fn init_tracing(verbosity: u8) { + let filter = match verbosity { + 0 => "warn", + 1 => "info", + 2 => "debug", + _ => "trace", + }; + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)), + ) + .try_init() + .ok(); +} diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs new file mode 100644 index 0000000..f688553 --- /dev/null +++ b/crates/presentation/src/main.rs @@ -0,0 +1,9 @@ +use anyhow::Result; +use clap::Parser; + +use archlens::Cli; + +fn main() -> Result<()> { + let args = Cli::parse(); + archlens::run(args) +} diff --git a/crates/presentation/tests/end_to_end_tests.rs b/crates/presentation/tests/end_to_end_tests.rs new file mode 100644 index 0000000..043eb96 --- /dev/null +++ b/crates/presentation/tests/end_to_end_tests.rs @@ -0,0 +1,133 @@ +use std::fs; + +use archlens::run; + +fn create_rust_project(dir: &std::path::Path) { + fs::create_dir_all(dir.join("src")).unwrap(); + fs::write( + dir.join("src/order.rs"), + "pub struct Order {\n pub id: u64,\n}\n", + ) + .unwrap(); + fs::write( + dir.join("src/service.rs"), + "pub struct OrderService {\n order: Order,\n}\n", + ) + .unwrap(); +} + +fn create_multi_module_project(dir: &std::path::Path) { + fs::create_dir_all(dir.join("src/orders")).unwrap(); + fs::create_dir_all(dir.join("src/billing")).unwrap(); + fs::write( + dir.join("src/orders/order.rs"), + "pub struct Order {\n pub id: u64,\n}\n", + ) + .unwrap(); + fs::write( + dir.join("src/orders/service.rs"), + "pub struct OrderService {\n order: Order,\n}\n", + ) + .unwrap(); + fs::write( + dir.join("src/billing/invoice.rs"), + "pub struct Invoice {\n pub total: f64,\n}\n", + ) + .unwrap(); +} + +#[test] +fn analyzes_rust_project_and_writes_mermaid_to_file() { + let project = tempfile::tempdir().unwrap(); + create_rust_project(project.path()); + + let output_dir = tempfile::tempdir().unwrap(); + let output_file = output_dir.path().join("arch.mmd"); + + run(archlens::CliArgs { + command: None, + path: project.path().to_path_buf(), + level: "type".to_string(), + format: "mermaid".to_string(), + output: Some(output_file.to_str().unwrap().to_string()), + config: None, + scope: None, + exclude: vec![], + split_by_module: false, + strict: false, + check: false, + verbose: 0, + }) + .unwrap(); + + let content = fs::read_to_string(&output_file).unwrap(); + assert!(content.contains("classDiagram")); + assert!(content.contains("Order")); + assert!(content.contains("OrderService")); +} + +#[test] +fn works_without_config_file() { + let project = tempfile::tempdir().unwrap(); + create_rust_project(project.path()); + + let output_dir = tempfile::tempdir().unwrap(); + let output_file = output_dir.path().join("arch.mmd"); + + let result = run(archlens::CliArgs { + command: None, + path: project.path().to_path_buf(), + level: "type".to_string(), + format: "mermaid".to_string(), + output: Some(output_file.to_str().unwrap().to_string()), + config: None, + scope: None, + exclude: vec![], + split_by_module: false, + strict: false, + check: false, + verbose: 0, + }); + + assert!(result.is_ok()); +} + +#[test] +fn split_by_module_writes_overview_and_per_module_files() { + let project = tempfile::tempdir().unwrap(); + create_multi_module_project(project.path()); + + let output_dir = tempfile::tempdir().unwrap(); + + run(archlens::CliArgs { + command: None, + path: project.path().to_path_buf(), + level: "module".to_string(), + format: "mermaid".to_string(), + output: Some(output_dir.path().to_str().unwrap().to_string()), + config: None, + scope: None, + exclude: vec![], + split_by_module: true, + strict: false, + check: false, + verbose: 0, + }) + .unwrap(); + + let overview = output_dir.path().join("overview.mmd"); + assert!(overview.exists(), "overview.mmd should exist"); + + let overview_content = fs::read_to_string(&overview).unwrap(); + assert!(overview_content.contains("graph TD") || overview_content.contains("classDiagram")); + + let entries: Vec<_> = fs::read_dir(output_dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + + assert!( + entries.len() > 1, + "should have overview + at least one module file" + ); +} diff --git a/docs/adr/0001-hexagonal-architecture-with-ddd.md b/docs/adr/0001-hexagonal-architecture-with-ddd.md new file mode 100644 index 0000000..605851b --- /dev/null +++ b/docs/adr/0001-hexagonal-architecture-with-ddd.md @@ -0,0 +1,51 @@ +# 0001 — Hexagonal Architecture with DDD + +**Status:** Accepted +**Date:** 2026-06-16 + +## Context + +Archlens is a language-agnostic architecture diagram generator. It needs to support multiple input languages (Rust, C#, Python), multiple output formats (Mermaid, ASCII, future: D2, interactive web), and multiple IO strategies (file, stdout). The tool must be extensible without modifying core logic. + +## Decision + +Use hexagonal architecture (ports and adapters) with DDD structuring. Workspace layout: + +``` +crates/ + domain/ — zero external deps (no tracing, no rayon, nothing) + application/ — depends on domain only + rayon, tracing (pragmatic utility exceptions) + adapters/ + tree-sitter/ — SourceAnalyzer (internal modules per language) + walkdir/ — FileDiscovery + mermaid/ — DiagramRenderer + ascii/ — DiagramRenderer + file-writer/ — OutputWriter + stdout-writer/ — OutputWriter + toml-config/ — ConfigLoader + presentation/ — CLI (clap), composition root, tracing-subscriber setup +``` + +**Dependency rules:** +- Domain depends on nothing +- Application depends on domain +- Adapters depend on domain (not application) +- Presentation depends on application + adapters (composition root) + +**Use cases** are generic over port traits (static dispatch), not trait objects. + +**Pragmatic exceptions:** `rayon` and `tracing` in application crate. These are general-purpose utilities, not external system adapters. Documented explicitly to avoid precedent creep. + +## Alternatives Considered + +- **Trait objects for DI:** Simpler type signatures but runtime dispatch overhead and less compile-time safety. Rejected — generics align better with Rust idioms and zero-cost goals. +- **DI container (shaku):** Unnecessary ceremony for Rust. Manual wiring in presentation is explicit and sufficient. +- **Domain with tracing dependency:** Rejected to keep domain fully pure. Application wraps domain calls in spans. + +## Consequences + +- Adding a new language = new module in tree-sitter adapter (or new adapter crate) +- Adding a new output format = new adapter crate implementing DiagramRenderer +- Adding a new output destination = new adapter crate implementing OutputWriter +- Use case type signatures carry multiple generic parameters (accepted tradeoff) +- Domain crate is testable with zero setup — no mocks for infrastructure needed diff --git a/docs/adr/0002-tree-sitter-for-parsing.md b/docs/adr/0002-tree-sitter-for-parsing.md new file mode 100644 index 0000000..2b2e3ae --- /dev/null +++ b/docs/adr/0002-tree-sitter-for-parsing.md @@ -0,0 +1,31 @@ +# 0002 — Tree-sitter for Source Code Parsing + +**Status:** Accepted +**Date:** 2026-06-16 + +## Context + +Archlens needs to extract type-level information (classes, structs, traits, interfaces, enums, fields, inheritance) from Rust, C#, and Python source code. The tool must be language-agnostic in design, fast enough for CI, and memory-efficient for large codebases. + +## Decision + +Use tree-sitter as the primary parsing backend. One `tree-sitter` adapter crate with internal modules per language. Each language module defines tree-sitter S-expression queries to extract CodeElements and Relationships. + +**Tradeoff accepted:** Tree-sitter provides syntactic analysis only — no cross-file type resolution, no generics resolution, no resolved imports. This is sufficient for architecture diagrams where we care about structural relationships visible in the source text. + +Semantic resolution is a future concern, handled by a separate adapter (e.g., `roslyn-adapter` for C#) implementing the same `SourceAnalyzer` port. + +## Alternatives Considered + +- **Custom parsers/lexers per language:** Full control but enormous implementation and maintenance effort. Rejected. +- **LSP servers:** Rich semantic info but heavy to run, hard to orchestrate in CI, each language needs its own server process. Rejected for CI use case. +- **Native AST APIs (Roslyn, rustc_ast, Python ast):** Very accurate but each is a completely different API and ecosystem. Can't run Roslyn from Rust easily. Rejected as primary approach — viable as future specialized adapters. +- **Regex/heuristic:** Breaks on edge cases. Not serious for a real tool. + +## Consequences + +- Single unified parsing approach across all languages +- Adding a new language = writing tree-sitter queries (hours, not weeks) +- No semantic type resolution — `use Foo` doesn't tell us which `Foo` across modules +- Memory-efficient: parse trees are per-file and dropped after extraction +- Fast: tree-sitter is incremental and optimized for performance