Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions src/archml/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,8 +289,10 @@ def _cmd_check(args: argparse.Namespace) -> int:

def _cmd_visualize(args: argparse.Namespace) -> int:
"""Handle the visualize subcommand."""
from archml.views.diagram import build_diagram_data, render_diagram
from archml.views.backend.diagram import render_diagram
from archml.views.placement import compute_layout
from archml.views.resolver import EntityNotFoundError, resolve_entity
from archml.views.topology import build_viz_diagram
from archml.workspace.config import LocalPathImport

directory = Path(args.directory).resolve()
Expand Down Expand Up @@ -339,16 +341,9 @@ def _cmd_visualize(args: argparse.Namespace) -> int:
return 1

output_path = Path(args.output)
data = build_diagram_data(entity)

try:
render_diagram(data, output_path)
except ImportError:
print(
"Error: 'diagrams' is not installed. Run 'pip install diagrams' to enable visualization.",
file=sys.stderr,
)
return 1
viz_diagram = build_viz_diagram(entity)
layout_plan = compute_layout(viz_diagram)
render_diagram(viz_diagram, layout_plan, output_path)

print(f"Diagram written to '{output_path}'.")
return 0
Expand Down
58 changes: 54 additions & 4 deletions src/archml/compiler/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
InterfaceRef,
System,
TypeDef,
UserDef,
)
from archml.model.types import (
DirectoryTypeRef,
Expand Down Expand Up @@ -76,6 +77,7 @@ def parse(source: str) -> ArchFile:
{
TokenType.SYSTEM,
TokenType.COMPONENT,
TokenType.USER,
TokenType.INTERFACE,
TokenType.TYPE,
TokenType.ENUM,
Expand Down Expand Up @@ -202,16 +204,20 @@ def _parse_top_level(self, result: ArchFile) -> None:
result.components.append(self._parse_component(is_external=False))
elif tok.type == TokenType.SYSTEM:
result.systems.append(self._parse_system(is_external=False))
elif tok.type == TokenType.USER:
result.users.append(self._parse_user(is_external=False))
elif tok.type == TokenType.EXTERNAL:
self._advance() # consume 'external'
inner = self._current()
if inner.type == TokenType.COMPONENT:
result.components.append(self._parse_component(is_external=True))
elif inner.type == TokenType.SYSTEM:
result.systems.append(self._parse_system(is_external=True))
elif inner.type == TokenType.USER:
result.users.append(self._parse_user(is_external=True))
else:
raise ParseError(
f"Expected 'component' or 'system' after 'external', got {inner.value!r}",
f"Expected 'component', 'system', or 'user' after 'external', got {inner.value!r}",
inner.line,
inner.column,
)
Expand Down Expand Up @@ -440,16 +446,21 @@ def _parse_system(self, is_external: bool) -> System:
system.components.append(self._parse_component(is_external=False))
elif self._check(TokenType.SYSTEM):
system.systems.append(self._parse_system(is_external=False))
elif self._check(TokenType.USER):
system.users.append(self._parse_user(is_external=False))
elif self._check(TokenType.EXTERNAL):
self._advance() # consume 'external'
inner = self._current()
if inner.type == TokenType.COMPONENT:
system.components.append(self._parse_component(is_external=True))
elif inner.type == TokenType.SYSTEM:
system.systems.append(self._parse_system(is_external=True))
elif inner.type == TokenType.USER:
system.users.append(self._parse_user(is_external=True))
else:
raise ParseError(
f"Expected 'component' or 'system' after 'external' inside system body, got {inner.value!r}",
f"Expected 'component', 'system', or 'user' after 'external'"
f" inside system body, got {inner.value!r}",
inner.line,
inner.column,
)
Expand All @@ -468,7 +479,7 @@ def _parse_system(self, is_external: bool) -> System:
return system

def _parse_use_statement(self, system: System) -> None:
"""Parse: use component <Name> | use system <Name>.
"""Parse: use component <Name> | use system <Name> | use user <Name>.

Creates a stub entity in the system. The validation layer resolves
stubs to their imported definitions.
Expand All @@ -483,13 +494,52 @@ def _parse_use_statement(self, system: System) -> None:
self._advance()
name_tok = self._expect(TokenType.IDENTIFIER)
system.systems.append(System(name=name_tok.value))
elif kind.type == TokenType.USER:
self._advance()
name_tok = self._expect(TokenType.IDENTIFIER)
system.users.append(UserDef(name=name_tok.value))
else:
raise ParseError(
f"Expected 'component' or 'system' after 'use', got {kind.value!r}",
f"Expected 'component', 'system', or 'user' after 'use', got {kind.value!r}",
kind.line,
kind.column,
)

# ------------------------------------------------------------------
# User declarations
# ------------------------------------------------------------------

def _parse_user(self, is_external: bool) -> UserDef:
"""Parse: [external] user <Name> { [attrs] (requires|provides)* }

Users are leaf nodes: they support title, description, tags, requires,
and provides, but no sub-entities or connections.
"""
self._expect(TokenType.USER)
name_tok = self._expect(TokenType.IDENTIFIER)
self._expect(TokenType.LBRACE)
user = UserDef(name=name_tok.value, is_external=is_external)
while not self._check(TokenType.RBRACE, TokenType.EOF):
if self._check(TokenType.TITLE):
user.title = self._parse_string_attr(TokenType.TITLE)
elif self._check(TokenType.DESCRIPTION):
user.description = self._parse_string_attr(TokenType.DESCRIPTION)
elif self._check(TokenType.TAGS):
user.tags = self._parse_tags()
elif self._check(TokenType.REQUIRES):
user.requires.append(self._parse_interface_ref(TokenType.REQUIRES))
elif self._check(TokenType.PROVIDES):
user.provides.append(self._parse_interface_ref(TokenType.PROVIDES))
else:
tok = self._current()
raise ParseError(
f"Unexpected token {tok.value!r} in user body",
tok.line,
tok.column,
)
self._expect(TokenType.RBRACE)
return user

# ------------------------------------------------------------------
# Connection declarations
# ------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions src/archml/compiler/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class TokenType(enum.Enum):
# Keywords
SYSTEM = "system"
COMPONENT = "component"
USER = "user"
INTERFACE = "interface"
TYPE = "type"
ENUM = "enum"
Expand Down Expand Up @@ -124,6 +125,7 @@ def tokenize(source: str) -> list[Token]:
_KEYWORDS: dict[str, TokenType] = {
"system": TokenType.SYSTEM,
"component": TokenType.COMPONENT,
"user": TokenType.USER,
"interface": TokenType.INTERFACE,
"type": TokenType.TYPE,
"enum": TokenType.ENUM,
Expand Down
86 changes: 78 additions & 8 deletions src/archml/compiler/semantic_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from dataclasses import dataclass

from archml.model.entities import ArchFile, Component, Connection, EnumDef, InterfaceDef, InterfaceRef, System
from archml.model.entities import ArchFile, Component, Connection, EnumDef, InterfaceDef, InterfaceRef, System, UserDef
from archml.model.types import FieldDef, ListTypeRef, MapTypeRef, NamedTypeRef, OptionalTypeRef, TypeRef

# ###############
Expand Down Expand Up @@ -107,9 +107,13 @@ def __init__(
) -> None:
self._file = arch_file
self._resolved = resolved_imports
# Top-level component and system names visible at file scope.
# Top-level component, system, and user names visible at file scope.
# These are valid connection endpoints from within any nested system.
self._file_entity_names: set[str] = {c.name for c in arch_file.components} | {s.name for s in arch_file.systems}
self._file_entity_names: set[str] = (
{c.name for c in arch_file.components}
| {s.name for s in arch_file.systems}
| {u.name for u in arch_file.users}
)

def analyze(self) -> list[SemanticError]:
"""Run all semantic checks and return collected errors."""
Expand Down Expand Up @@ -180,7 +184,18 @@ def analyze(self) -> list[SemanticError]:
)
)

# 7. Validate import entities against resolved source files.
# 7. Check top-level users.
for user in self._file.users:
errors.extend(
_check_user(
user,
all_interface_plain_names,
local_interface_defs,
imported_names,
)
)

# 8. Validate import entities against resolved source files.
errors.extend(self._check_import_resolutions())

return errors
Expand Down Expand Up @@ -281,12 +296,22 @@ def _check_system(
"Duplicate sub-system name '{}' in " + ctx,
)
)
# Check for duplicate user names within this system.
errors.extend(
_check_duplicate_names(
[u.name for u in system.users],
"Duplicate user name '{}' in " + ctx,
)
)

# Check for name conflicts between components and sub-systems.
# Check for name conflicts between components, sub-systems, and users.
comp_names = {c.name for c in system.components}
sys_names = {s.name for s in system.systems}
user_names = {u.name for u in system.users}
for name in sorted(comp_names & sys_names):
errors.append(SemanticError(f"{ctx}: name '{name}' is used for both a component and a sub-system"))
for name in sorted((comp_names | sys_names) & user_names):
errors.append(SemanticError(f"{ctx}: name '{name}' is used for both a user and a component or sub-system"))

# Check requires / provides interface references.
for ref in system.requires:
Expand All @@ -313,12 +338,12 @@ def _check_system(
)

# Connection endpoints in a system may reference:
# 1. Direct members of this system (components and sub-systems),
# 1. Direct members of this system (components, sub-systems, and users),
# 2. Top-level entities in the file (e.g. external systems defined
# at the top level and referenced in an internal connection), or
# 3. Imported names (brought in via `from ... import` and used via
# `use component/system`).
member_names = comp_names | sys_names
# `use component/system/user`).
member_names = comp_names | sys_names | user_names
connection_scope = member_names | self._file_entity_names | imported_names
for conn in system.connections:
errors.extend(
Expand Down Expand Up @@ -353,6 +378,15 @@ def _check_system(
imported_names,
)
)
for user in system.users:
errors.extend(
_check_user(
user,
all_interface_names,
local_interface_defs,
imported_names,
)
)

return errors

Expand Down Expand Up @@ -401,6 +435,8 @@ def _assign_qualified_names(arch_file: ArchFile, *, file_key: str | None = None)
_assign_component_qualified_names(comp, prefix=file_prefix)
for system in arch_file.systems:
_assign_system_qualified_names(system, prefix=file_prefix)
for user in arch_file.users:
_assign_user_qualified_name(user, prefix=file_prefix)


def _assign_component_qualified_names(comp: Component, prefix: str | None) -> None:
Expand All @@ -410,13 +446,20 @@ def _assign_component_qualified_names(comp: Component, prefix: str | None) -> No
_assign_component_qualified_names(sub, prefix=comp.qualified_name)


def _assign_user_qualified_name(user: UserDef, prefix: str | None) -> None:
"""Set the qualified name for a user entity."""
user.qualified_name = f"{prefix}::{user.name}" if prefix else user.name


def _assign_system_qualified_names(system: System, prefix: str | None) -> None:
"""Recursively set qualified names for a system and all its children."""
system.qualified_name = f"{prefix}::{system.name}" if prefix else system.name
for comp in system.components:
_assign_component_qualified_names(comp, prefix=system.qualified_name)
for sub_sys in system.systems:
_assign_system_qualified_names(sub_sys, prefix=system.qualified_name)
for user in system.users:
_assign_user_qualified_name(user, prefix=system.qualified_name)


def _check_duplicate_imports(arch_file: ArchFile) -> list[SemanticError]:
Expand Down Expand Up @@ -451,6 +494,7 @@ def _collect_all_top_level_names(arch_file: ArchFile) -> set[str]:
names.update(i.name for i in arch_file.interfaces)
names.update(c.name for c in arch_file.components)
names.update(s.name for s in arch_file.systems)
names.update(u.name for u in arch_file.users)
return names


Expand Down Expand Up @@ -517,6 +561,12 @@ def _check_top_level_duplicates(arch_file: ArchFile) -> list[SemanticError]:
"Duplicate system name '{}'",
)
)
errors.extend(
_check_duplicate_names(
[u.name for u in arch_file.users],
"Duplicate user name '{}'",
)
)

# An enum and a type with the same name create ambiguity for field type
# references.
Expand Down Expand Up @@ -609,6 +659,26 @@ def _check_interface_ref(
return errors


def _check_user(
user: UserDef,
all_interface_names: set[str],
local_interface_defs: dict[tuple[str, str | None], InterfaceDef],
imported_names: set[str],
) -> list[SemanticError]:
"""Check requires/provides interface references on a user entity."""
errors: list[SemanticError] = []
ctx = f"user '{user.name}'"
for ref in user.requires:
errors.extend(
_check_interface_ref(ctx, ref, all_interface_names, local_interface_defs, imported_names, "requires")
)
for ref in user.provides:
errors.extend(
_check_interface_ref(ctx, ref, all_interface_names, local_interface_defs, imported_names, "provides")
)
return errors


def _check_connection(
ctx: str,
conn: Connection,
Expand Down
20 changes: 20 additions & 0 deletions src/archml/model/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,23 @@ class Connection(BaseModel):
description: str | None = None


class UserDef(BaseModel):
"""A human actor (role or persona) that interacts with the system.

Users are leaf nodes: they declare required and provided interfaces but
cannot contain sub-entities or connections.
"""

name: str
title: str | None = None
description: str | None = None
tags: list[str] = _Field(default_factory=list)
requires: list[InterfaceRef] = _Field(default_factory=list)
provides: list[InterfaceRef] = _Field(default_factory=list)
is_external: bool = False
qualified_name: str = ""


class Component(BaseModel):
"""A module with declared interface ports and optional nested sub-components."""

Expand All @@ -97,6 +114,7 @@ class System(BaseModel):
provides: list[InterfaceRef] = _Field(default_factory=list)
components: list[Component] = _Field(default_factory=list)
systems: list[System] = _Field(default_factory=list)
users: list[UserDef] = _Field(default_factory=list)
connections: list[Connection] = _Field(default_factory=list)
is_external: bool = False
qualified_name: str = ""
Expand All @@ -118,8 +136,10 @@ class ArchFile(BaseModel):
interfaces: list[InterfaceDef] = _Field(default_factory=list)
components: list[Component] = _Field(default_factory=list)
systems: list[System] = _Field(default_factory=list)
users: list[UserDef] = _Field(default_factory=list)


# Resolve forward references in self-referential models.
Component.model_rebuild()
System.model_rebuild()
ArchFile.model_rebuild()
4 changes: 4 additions & 0 deletions src/archml/views/backend/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2026 ArchML Contributors
# SPDX-License-Identifier: Apache-2.0

"""Rendering backends for ArchML visualization diagrams."""
Loading