diff --git a/README.md b/README.md index 97e4eb6..045a505 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,13 @@ type OrderItem { field unit_price: Decimal } -enum OrderStatus { Pending Confirmed Shipped Delivered Cancelled } +enum OrderStatus { + Pending + Confirmed + Shipped + Delivered + Cancelled +} interface OrderRequest { field order_id: String @@ -94,9 +100,14 @@ system ECommerce { provides InventoryStatus } - connect OrderService -> PaymentGateway by PaymentRequest - connect OrderService -> InventoryManager by InventoryCheck { protocol = "HTTP" } - connect PaymentGateway -> StripeAPI by PaymentRequest { protocol = "HTTP" async = true } + connect OrderService -> PaymentGateway by PaymentRequest + connect OrderService -> InventoryManager by InventoryCheck { + protocol = "HTTP" + } + connect PaymentGateway -> StripeAPI by PaymentRequest { + protocol = "HTTP" + async = true + } } ``` @@ -123,6 +134,17 @@ Primitive types: `String`, `Int`, `Float`, `Decimal`, `Bool`, `Bytes`, `Timestam Container types: `List`, `Map`, `Optional` Filesystem types: `File` (with `filetype`, `schema`), `Directory` (with `schema`) +Multi-line descriptions use triple-quoted strings: + +``` +description = """ +Accepts and validates customer orders. +Delegates payment to PaymentGateway. +""" +``` + +Enum values and connection block attributes each occupy their own line — no commas needed. + Full syntax reference: [docs/LANGUAGE_SYNTAX.md](docs/LANGUAGE_SYNTAX.md) ## Installation diff --git a/docs/LANGUAGE_SYNTAX.md b/docs/LANGUAGE_SYNTAX.md index 23e53e0..ac42c3b 100644 --- a/docs/LANGUAGE_SYNTAX.md +++ b/docs/LANGUAGE_SYNTAX.md @@ -20,6 +20,18 @@ ArchML files use the `.archml` extension. A file contains one or more top-level Strings use double quotes. Identifiers are unquoted alphanumeric names with underscores (e.g., `order_service`). Every named entity has an optional human-readable `title` and `description`. +Multi-line text is written with triple-quoted strings (`"""`): + +``` +description = """ +Accepts and validates customer orders. +Delegates payment processing to the PaymentGateway +and inventory checks to the InventoryManager. +""" +``` + +Single-quoted strings may not contain a literal newline character but support the same `\n`, `\t`, `\\`, `\"` escape sequences as triple-quoted strings. + ## Type System ### Primitive Types @@ -53,7 +65,7 @@ field artifact: Directory { ### Enumerations -The `enum` keyword defines a constrained set of named values: +The `enum` keyword defines a constrained set of named values. Each value must appear on its own line: ``` enum OrderStatus { @@ -222,7 +234,7 @@ connect ServiceA -> ServiceB by RequestToB connect ServiceB -> ServiceA by ResponseToA ``` -Connections may carry annotations: +Connections may carry annotations. Each attribute must appear on its own line: ``` connect OrderService -> PaymentGateway by PaymentRequest { @@ -463,6 +475,7 @@ system ECommerce { connect PaymentGateway -> StripeAPI by PaymentRequest { protocol = "HTTP" async = true + description = "Delegate payment processing to Stripe." } } ``` diff --git a/src/archml/compiler/parser.py b/src/archml/compiler/parser.py index 22f6f10..7083118 100644 --- a/src/archml/compiler/parser.py +++ b/src/archml/compiler/parser.py @@ -268,11 +268,16 @@ def _parse_identifier_list(self) -> list[str]: # ------------------------------------------------------------------ def _parse_enum(self) -> EnumDef: - """Parse: enum { [attrs] * }""" + """Parse: enum { [attrs] * } + + Each enum value must appear on its own line (line number strictly + greater than the opening brace or the previous value). + """ self._expect(TokenType.ENUM) name_tok = self._expect(TokenType.IDENTIFIER) - self._expect(TokenType.LBRACE) + lbrace = self._expect(TokenType.LBRACE) enum_def = EnumDef(name=name_tok.value) + last_value_line = lbrace.line while not self._check(TokenType.RBRACE, TokenType.EOF): if self._check(TokenType.TITLE): enum_def.title = self._parse_string_attr(TokenType.TITLE) @@ -281,7 +286,15 @@ def _parse_enum(self) -> EnumDef: elif self._check(TokenType.TAGS): enum_def.tags = self._parse_tags() elif self._check(TokenType.IDENTIFIER): - value_tok = self._advance() + value_tok = self._current() + if value_tok.line <= last_value_line: + raise ParseError( + f"Enum value {value_tok.value!r} must be on a new line", + value_tok.line, + value_tok.column, + ) + last_value_line = value_tok.line + self._advance() enum_def.values.append(value_tok.value) else: tok = self._current() @@ -482,7 +495,12 @@ def _parse_use_statement(self, system: System) -> None: # ------------------------------------------------------------------ def _parse_connection(self) -> Connection: - """Parse: connect -> by [@version] [{ ... }]""" + """Parse: connect -> by [@version] [{ ... }] + + Each attribute inside the annotation block must appear on its own line + (line number strictly greater than the opening brace or the previous + attribute's last token). + """ self._expect(TokenType.CONNECT) source_tok = self._expect(TokenType.IDENTIFIER) self._expect(TokenType.ARROW) @@ -500,9 +518,18 @@ def _parse_connection(self) -> Connection: interface=InterfaceRef(name=iface_name_tok.value, version=version), ) if self._check(TokenType.LBRACE): - self._advance() # consume { + lbrace = self._advance() # consume { + last_attr_line = lbrace.line while not self._check(TokenType.RBRACE, TokenType.EOF): + attr_tok = self._current() + if attr_tok.line <= last_attr_line: + raise ParseError( + "Connection attributes must each be on a new line", + attr_tok.line, + attr_tok.column, + ) self._parse_connection_attr(conn) + last_attr_line = self._tokens[self._pos - 1].line self._expect(TokenType.RBRACE) return conn diff --git a/src/archml/compiler/scanner.py b/src/archml/compiler/scanner.py index 60c6379..ab78d3f 100644 --- a/src/archml/compiler/scanner.py +++ b/src/archml/compiler/scanner.py @@ -278,8 +278,25 @@ def _scan_token(self) -> None: # ------------------------------------------------------------------ def _scan_string(self, line: int, col: int) -> None: - """Scan a double-quoted string literal with escape sequences.""" + """Scan a double-quoted string literal. + + Triple-quoted strings (\"\"\"...\"\"\") allow literal newlines in the + content and are used for multi-line descriptions. Single-quoted + strings support \\n, \\t, \\\\, and \\\" escape sequences but may not + contain a literal newline character. + """ self._advance() # opening " + # Check for triple-quoted string: "" or """ + if self._pos < len(self._source) and self._current() == '"': + self._advance() # second " + if self._pos < len(self._source) and self._current() == '"': + self._advance() # third " + self._scan_triple_quoted_string(line, col) + return + # Two quotes consumed → empty string + self._tokens.append(Token(TokenType.STRING, "", line, col)) + return + # Regular single-quoted string chars: list[str] = [] while self._pos < len(self._source): ch = self._current() @@ -314,6 +331,52 @@ def _scan_string(self, line: int, col: int) -> None: self._advance() raise LexerError("Unterminated string literal", line, col) + def _scan_triple_quoted_string(self, line: int, col: int) -> None: + """Scan a triple-quoted string (\"\"\"...\"\"\"). + + Allows literal newlines. Supports the same escape sequences as + single-quoted strings (\\n, \\t, \\\\, \\\"). + """ + chars: list[str] = [] + while self._pos < len(self._source): + ch = self._current() + # Check for closing """ + if ( + ch == '"' + and self._pos + 2 < len(self._source) + and self._source[self._pos + 1] == '"' + and self._source[self._pos + 2] == '"' + ): + self._advance() # first " + self._advance() # second " + self._advance() # third " + self._tokens.append(Token(TokenType.STRING, "".join(chars), line, col)) + return + if ch == "\\": + self._advance() + if self._pos >= len(self._source): + raise LexerError("Unterminated triple-quoted string literal", line, col) + esc = self._current() + if esc == "n": + chars.append("\n") + elif esc == "t": + chars.append("\t") + elif esc == "\\": + chars.append("\\") + elif esc == '"': + chars.append('"') + else: + raise LexerError( + f"Invalid escape sequence: '\\{esc}'", + self._line, + self._column, + ) + self._advance() + else: + chars.append(ch) + self._advance() + raise LexerError("Unterminated triple-quoted string literal", line, col) + def _scan_number(self, line: int, col: int) -> None: """Scan an integer or floating-point literal. diff --git a/src/archml/views/diagram.py b/src/archml/views/diagram.py index 7eaa0ea..ad3ac07 100644 --- a/src/archml/views/diagram.py +++ b/src/archml/views/diagram.py @@ -173,7 +173,7 @@ def render_diagram(data: DiagramData, output_path: Path) -> None: # Custom node classes are defined locally to keep the diagrams import lazy # (it is an optional dependency that requires Graphviz to be installed). - class _TerminalNode(Node): # type: ignore[misc] + class _TerminalNode(Node): """Styled box for an interface terminal (requires or provides).""" _icon_dir = None @@ -187,7 +187,7 @@ class _TerminalNode(Node): # type: ignore[misc] "penwidth": "1.5", } - class _EntityNode(Node): # type: ignore[misc] + class _EntityNode(Node): """Styled box for a leaf entity (component or system with no children).""" _icon_dir = None @@ -201,7 +201,7 @@ class _EntityNode(Node): # type: ignore[misc] "penwidth": "2", } - class _ChildNode(Node): # type: ignore[misc] + class _ChildNode(Node): """Styled box for a child component or system inside an entity cluster.""" _icon_dir = None @@ -234,7 +234,7 @@ class _ChildNode(Node): # type: ignore[misc] # that cross-cluster edges are handled correctly by Graphviz). for conn in data.connections: if conn.source in child_nodes and conn.target in child_nodes: - child_nodes[conn.source] >> Edge(label=conn.label) >> child_nodes[conn.target] # type: ignore[operator] + child_nodes[conn.source] >> Edge(label=conn.label) >> child_nodes[conn.target] # Children with no incoming internal connection are natural entry # points for requires terminals; those with no outgoing connection @@ -259,11 +259,11 @@ class _ChildNode(Node): # type: ignore[misc] # --- Terminal ↔ entity edges --- for req_node in req_nodes.values(): for entry in entry_nodes: - req_node >> Edge() >> entry # type: ignore[operator] + req_node >> Edge() >> entry for prov_node in prov_nodes.values(): for exit_node in exit_nodes: - exit_node >> Edge() >> prov_node # type: ignore[operator] + exit_node >> Edge() >> prov_node # ################ diff --git a/tests/compiler/test_artifact.py b/tests/compiler/test_artifact.py index c8c6d29..4b2c0ae 100644 --- a/tests/compiler/test_artifact.py +++ b/tests/compiler/test_artifact.py @@ -373,7 +373,10 @@ def test_artifact_file_is_valid_json(self, tmp_path: Path) -> None: def test_roundtrip_parsed_file(self, tmp_path: Path) -> None: """Parsing a real .archml file and roundtripping through artifact.""" source = """ -enum Status { Active Inactive } +enum Status { + Active + Inactive +} type Config { field timeout: Int diff --git a/tests/compiler/test_build.py b/tests/compiler/test_build.py index 02376d6..acdaa2a 100644 --- a/tests/compiler/test_build.py +++ b/tests/compiler/test_build.py @@ -92,7 +92,11 @@ def test_compiles_file_with_enum_and_type(self, tmp_path: Path) -> None: _write( src / "types.archml", """ -enum Color { Red Green Blue } +enum Color { + Red + Green + Blue +} type Point { field x: Int field y: Int } """, ) @@ -621,8 +625,12 @@ def test_multiple_semantic_errors_in_message(self, tmp_path: Path) -> None: _write( src / "bad.archml", """ -enum Dup { A } -enum Dup { B } +enum Dup { + A +} +enum Dup { + B +} """, ) with pytest.raises(CompilerError, match="Semantic errors"): diff --git a/tests/compiler/test_compiler_integration.py b/tests/compiler/test_compiler_integration.py index 93a0a3c..0ba5f21 100644 --- a/tests/compiler/test_compiler_integration.py +++ b/tests/compiler/test_compiler_integration.py @@ -337,8 +337,12 @@ def test_large_types_file_parses_and_passes(self) -> None: def test_multiple_errors_in_one_file(self) -> None: """A file with many problems should report all of them, not just the first.""" source = """ -enum Dup { A } -enum Dup { B } +enum Dup { + A +} +enum Dup { + B +} type Bad { field x: UnknownType } diff --git a/tests/compiler/test_parser.py b/tests/compiler/test_parser.py index 5bacdf9..432936f 100644 --- a/tests/compiler/test_parser.py +++ b/tests/compiler/test_parser.py @@ -131,7 +131,7 @@ def test_empty_enum(self) -> None: assert enum.values == [] def test_enum_with_single_value(self) -> None: - result = _parse("enum Status { Active }") + result = _parse("enum Status {\n Active\n}") assert result.enums[0].values == ["Active"] def test_enum_with_multiple_values(self) -> None: @@ -201,24 +201,34 @@ def test_enum_with_all_attributes(self) -> None: assert enum.values == ["Pending", "Confirmed"] def test_enum_title_default_is_none(self) -> None: - result = _parse("enum Status { Active }") + result = _parse("enum Status {\n Active\n}") assert result.enums[0].title is None def test_enum_description_default_is_none(self) -> None: - result = _parse("enum Status { Active }") + result = _parse("enum Status {\n Active\n}") assert result.enums[0].description is None def test_enum_tags_default_is_empty(self) -> None: - result = _parse("enum Status { Active }") + result = _parse("enum Status {\n Active\n}") assert result.enums[0].tags == [] def test_multiple_enums(self) -> None: - source = "enum A { X }\nenum B { Y }" + source = "enum A {\n X\n}\nenum B {\n Y\n}" result = _parse(source) assert len(result.enums) == 2 assert result.enums[0].name == "A" assert result.enums[1].name == "B" + def test_enum_value_on_same_line_as_lbrace_raises(self) -> None: + with pytest.raises(ParseError) as exc_info: + _parse("enum Status { Active }") + assert "new line" in str(exc_info.value) + + def test_enum_values_on_same_line_as_each_other_raises(self) -> None: + with pytest.raises(ParseError) as exc_info: + _parse("enum Status {\n Active Inactive\n}") + assert "new line" in str(exc_info.value) + # ############### # Type Declarations @@ -1060,6 +1070,112 @@ def test_connection_endpoint_entities(self) -> None: assert conn.source.entity == "SourceService" assert conn.target.entity == "TargetService" + def test_connection_attr_on_same_line_as_lbrace_raises(self) -> None: + with pytest.raises(ParseError) as exc_info: + _parse('system S { connect A -> B by X { protocol = "HTTP" } }') + assert "new line" in str(exc_info.value) + + def test_connection_attrs_on_same_line_as_each_other_raises(self) -> None: + source = """\ +system S { + connect A -> B by X { + protocol = "HTTP" async = true + } +}""" + with pytest.raises(ParseError) as exc_info: + _parse(source) + assert "new line" in str(exc_info.value) + + +# ############### +# Multi-Line Descriptions +# ############### + + +class TestMultiLineDescriptions: + def test_triple_quoted_description_on_interface(self) -> None: + source = '''\ +interface OrderRequest { + description = """ + A multi-line + description. + """ +}''' + result = _parse(source) + desc = result.interfaces[0].description + assert desc is not None + assert "multi-line" in desc + assert "\n" in desc + + def test_triple_quoted_description_on_component(self) -> None: + source = '''\ +component OrderService { + description = """Accepts and validates +customer orders across multiple channels.""" +}''' + result = _parse(source) + desc = result.components[0].description + assert desc is not None + assert "channels" in desc + + def test_triple_quoted_description_on_system(self) -> None: + source = '''\ +system ECommerce { + description = """ + Customer-facing online store. + Handles orders, payments, and inventory. + """ +}''' + result = _parse(source) + assert result.systems[0].description is not None + + def test_triple_quoted_description_on_enum(self) -> None: + source = '''\ +enum OrderStatus { + description = """ + Lifecycle states of a customer order. + Used throughout the order processing pipeline. + """ + Pending + Confirmed +}''' + result = _parse(source) + enum = result.enums[0] + assert enum.description is not None + assert "Lifecycle" in enum.description + assert enum.values == ["Pending", "Confirmed"] + + def test_triple_quoted_description_on_type(self) -> None: + source = '''\ +type OrderItem { + description = """Represents a single line +item within an order.""" + field product_id: String +}''' + result = _parse(source) + assert result.types[0].description is not None + + def test_triple_quoted_schema_on_field(self) -> None: + source = '''\ +interface Report { + field summary: File { + filetype = "PDF" + schema = """ + Page 1: executive summary. + Page 2+: detailed breakdown by region. + """ + } +}''' + result = _parse(source) + field = result.interfaces[0].fields[0] + assert field.schema_ref is not None + assert "executive" in field.schema_ref + + def test_triple_quoted_and_single_quoted_interchangeable(self) -> None: + single = _parse('interface I { description = "Simple description." }') + triple = _parse('interface I { description = """Simple description.""" }') + assert single.interfaces[0].description == triple.interfaces[0].description + # ############### # Tags Parsing @@ -1088,7 +1204,7 @@ def test_tags_on_system(self) -> None: assert result.systems[0].tags == ["platform"] def test_tags_on_enum(self) -> None: - result = _parse('enum E { tags = ["domain"] Active }') + result = _parse('enum E {\n tags = ["domain"]\n Active\n}') assert result.enums[0].tags == ["domain"] def test_tags_on_type(self) -> None: @@ -1109,7 +1225,9 @@ class TestMixedTopLevelDeclarations: def test_all_top_level_kinds_in_sequence(self) -> None: source = """\ from interfaces/order import OrderRequest -enum OrderStatus { Pending } +enum OrderStatus { + Pending +} type OrderItem { field product_id: String } interface OrderRequest { field order_id: String } component OrderService { requires OrderRequest } @@ -1571,7 +1689,7 @@ def test_import_missing_import_keyword(self) -> None: def test_unknown_connection_attribute(self) -> None: with pytest.raises(ParseError) as exc_info: - _parse("system S { connect A -> B by I { timeout = 30 } }") + _parse("system S {\n connect A -> B by I {\n timeout = 30\n }\n}") assert "Unknown connection attribute" in str(exc_info.value) def test_unknown_field_annotation(self) -> None: @@ -1671,7 +1789,7 @@ def test_type_with_directory_field(self) -> None: assert "manifests" in field.schema_ref # type: ignore[operator] def test_enum_values_preserve_order(self) -> None: - source = "enum Color { Red Green Blue }" + source = "enum Color {\n Red\n Green\n Blue\n}" result = _parse(source) assert result.enums[0].values == ["Red", "Green", "Blue"] diff --git a/tests/compiler/test_scanner.py b/tests/compiler/test_scanner.py index b81cd49..2f00800 100644 --- a/tests/compiler/test_scanner.py +++ b/tests/compiler/test_scanner.py @@ -345,6 +345,105 @@ def test_multiple_strings(self) -> None: assert tokens[1].value == "bar" +# ############### +# Triple-Quoted String Literals +# ############### + + +class TestTripleQuotedStrings: + def test_simple_triple_quoted_string(self) -> None: + tokens = _tokens_no_eof('"""hello"""') + assert len(tokens) == 1 + assert tokens[0].type == TokenType.STRING + assert tokens[0].value == "hello" + + def test_empty_triple_quoted_string(self) -> None: + tokens = _tokens_no_eof('""""""') + assert tokens[0].type == TokenType.STRING + assert tokens[0].value == "" + + def test_triple_quoted_with_literal_newline(self) -> None: + tokens = _tokens_no_eof('"""line1\nline2"""') + assert tokens[0].type == TokenType.STRING + assert tokens[0].value == "line1\nline2" + + def test_triple_quoted_with_multiple_newlines(self) -> None: + source = '"""\nfirst\nsecond\nthird\n"""' + tokens = _tokens_no_eof(source) + assert tokens[0].type == TokenType.STRING + assert tokens[0].value == "\nfirst\nsecond\nthird\n" + + def test_triple_quoted_with_single_double_quote_inside(self) -> None: + tokens = _tokens_no_eof('"""say "hi" there"""') + assert tokens[0].value == 'say "hi" there' + + def test_triple_quoted_with_two_double_quotes_inside(self) -> None: + tokens = _tokens_no_eof('"""a ""b"""') + assert tokens[0].value == 'a ""b' + + def test_triple_quoted_with_escape_newline(self) -> None: + tokens = _tokens_no_eof(r'"""col1\ncol2"""') + assert tokens[0].value == "col1\ncol2" + + def test_triple_quoted_with_escape_tab(self) -> None: + tokens = _tokens_no_eof(r'"""a\tb"""') + assert tokens[0].value == "a\tb" + + def test_triple_quoted_with_escape_backslash(self) -> None: + tokens = _tokens_no_eof(r'"""back\\slash"""') + assert tokens[0].value == "back\\slash" + + def test_triple_quoted_with_escape_quote(self) -> None: + tokens = _tokens_no_eof(r'"""say \"hi\""""') + assert tokens[0].value == 'say "hi"' + + def test_triple_quoted_produces_string_token(self) -> None: + tokens = _tokens_no_eof('"""content"""') + assert tokens[0].type == TokenType.STRING + + def test_triple_quoted_start_position_at_opening_quotes(self) -> None: + tokens = _tokens_no_eof(' """hello"""') + assert tokens[0].column == 4 + + def test_triple_quoted_multiline_source_location(self) -> None: + # Token line/col recorded at opening """ + source = '"""line1\nline2"""' + tokens = _tokens_no_eof(source) + assert tokens[0].line == 1 + assert tokens[0].column == 1 + + def test_unterminated_triple_quoted_raises(self) -> None: + with pytest.raises(LexerError) as exc_info: + tokenize('"""unterminated') + assert "Unterminated triple-quoted string" in str(exc_info.value) + + def test_unterminated_triple_quoted_two_closing_raises(self) -> None: + with pytest.raises(LexerError): + tokenize('"""only two closing""') + + def test_triple_quoted_invalid_escape_raises(self) -> None: + with pytest.raises(LexerError) as exc_info: + tokenize(r'"""bad\xescape"""') + assert "Invalid escape sequence" in str(exc_info.value) + + def test_regular_empty_string_still_works(self) -> None: + tokens = _tokens_no_eof('""') + assert tokens[0].type == TokenType.STRING + assert tokens[0].value == "" + + def test_triple_quoted_followed_by_token(self) -> None: + tokens = _tokens_no_eof('"""hello""" system') + assert len(tokens) == 2 + assert tokens[0].type == TokenType.STRING + assert tokens[0].value == "hello" + assert tokens[1].type == TokenType.SYSTEM + + def test_triple_quoted_in_description_assignment(self) -> None: + source = 'description = """multi\nline"""' + types = _types(source) + assert types == [TokenType.DESCRIPTION, TokenType.EQUALS, TokenType.STRING] + + # ############### # Number Literals # ############### diff --git a/tests/compiler/test_semantic_analysis.py b/tests/compiler/test_semantic_analysis.py index ca4c50a..a804bd3 100644 --- a/tests/compiler/test_semantic_analysis.py +++ b/tests/compiler/test_semantic_analysis.py @@ -255,8 +255,12 @@ class TestDuplicateTopLevelNames: def test_duplicate_enum_name(self) -> None: _assert_error( """ -enum Status { Active } -enum Status { Inactive } +enum Status { + Active +} +enum Status { + Inactive +} """, "Duplicate enum name 'Status'", ) @@ -315,7 +319,9 @@ def test_different_interface_versions_are_ok(self) -> None: def test_enum_and_type_same_name_conflict(self) -> None: _assert_error( """ -enum Foo { Bar } +enum Foo { + Bar +} type Foo { field x: String } """, "Name 'Foo' is defined as both an enum and a type", @@ -323,9 +329,15 @@ def test_enum_and_type_same_name_conflict(self) -> None: def test_third_occurrence_of_duplicate_name(self) -> None: errors = _analyze(""" -enum Status { Active } -enum Status { Inactive } -enum Status { Deleted } +enum Status { + Active +} +enum Status { + Inactive +} +enum Status { + Deleted +} """) # Only one error reported per unique duplicate name messages = _messages(errors) @@ -333,8 +345,12 @@ def test_third_occurrence_of_duplicate_name(self) -> None: def test_multiple_duplicates_in_same_file(self) -> None: errors = _analyze(""" -enum Status { Active } -enum Status { Inactive } +enum Status { + Active +} +enum Status { + Inactive +} type Address { field x: String } type Address { field y: String } """) @@ -372,8 +388,16 @@ def test_no_duplicates_ok(self) -> None: def test_duplicate_in_one_enum_not_other(self) -> None: errors = _analyze(""" -enum A { X X Y } -enum B { X Y Z } +enum A { + X + X + Y +} +enum B { + X + Y + Z +} """) messages = _messages(errors) assert any("in enum 'A'" in m for m in messages) @@ -381,7 +405,11 @@ def test_duplicate_in_one_enum_not_other(self) -> None: def test_triple_duplicate_reports_once(self) -> None: errors = _analyze(""" -enum Foo { A A A } +enum Foo { + A + A + A +} """) messages = _messages(errors) assert messages.count("Duplicate value 'A' in enum 'Foo'") == 1 @@ -460,7 +488,9 @@ def test_undefined_named_type_in_interface_field(self) -> None: def test_enum_used_as_field_type_ok(self) -> None: _assert_clean(""" -enum Status { Active } +enum Status { + Active +} type Record { field status: Status } """) @@ -880,7 +910,7 @@ def test_imported_system_found_in_resolved(self) -> None: ) def test_imported_enum_found_in_resolved(self) -> None: - source_file = parse("enum Status { Active Inactive }") + source_file = parse("enum Status {\n Active\n Inactive\n}") _assert_clean( "from enums import Status", resolved_imports={"enums": source_file},