rdf-construct 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rdf_construct/__init__.py +12 -0
- rdf_construct/__main__.py +0 -0
- rdf_construct/cli.py +3429 -0
- rdf_construct/core/__init__.py +33 -0
- rdf_construct/core/config.py +116 -0
- rdf_construct/core/ordering.py +219 -0
- rdf_construct/core/predicate_order.py +212 -0
- rdf_construct/core/profile.py +157 -0
- rdf_construct/core/selector.py +64 -0
- rdf_construct/core/serialiser.py +232 -0
- rdf_construct/core/utils.py +89 -0
- rdf_construct/cq/__init__.py +77 -0
- rdf_construct/cq/expectations.py +365 -0
- rdf_construct/cq/formatters/__init__.py +45 -0
- rdf_construct/cq/formatters/json.py +104 -0
- rdf_construct/cq/formatters/junit.py +104 -0
- rdf_construct/cq/formatters/text.py +146 -0
- rdf_construct/cq/loader.py +300 -0
- rdf_construct/cq/runner.py +321 -0
- rdf_construct/diff/__init__.py +59 -0
- rdf_construct/diff/change_types.py +214 -0
- rdf_construct/diff/comparator.py +338 -0
- rdf_construct/diff/filters.py +133 -0
- rdf_construct/diff/formatters/__init__.py +71 -0
- rdf_construct/diff/formatters/json.py +192 -0
- rdf_construct/diff/formatters/markdown.py +210 -0
- rdf_construct/diff/formatters/text.py +195 -0
- rdf_construct/docs/__init__.py +60 -0
- rdf_construct/docs/config.py +238 -0
- rdf_construct/docs/extractors.py +603 -0
- rdf_construct/docs/generator.py +360 -0
- rdf_construct/docs/renderers/__init__.py +7 -0
- rdf_construct/docs/renderers/html.py +803 -0
- rdf_construct/docs/renderers/json.py +390 -0
- rdf_construct/docs/renderers/markdown.py +628 -0
- rdf_construct/docs/search.py +278 -0
- rdf_construct/docs/templates/html/base.html.jinja +44 -0
- rdf_construct/docs/templates/html/class.html.jinja +152 -0
- rdf_construct/docs/templates/html/hierarchy.html.jinja +28 -0
- rdf_construct/docs/templates/html/index.html.jinja +110 -0
- rdf_construct/docs/templates/html/instance.html.jinja +90 -0
- rdf_construct/docs/templates/html/namespaces.html.jinja +37 -0
- rdf_construct/docs/templates/html/property.html.jinja +124 -0
- rdf_construct/docs/templates/html/single_page.html.jinja +169 -0
- rdf_construct/lint/__init__.py +75 -0
- rdf_construct/lint/config.py +214 -0
- rdf_construct/lint/engine.py +396 -0
- rdf_construct/lint/formatters.py +327 -0
- rdf_construct/lint/rules.py +692 -0
- rdf_construct/localise/__init__.py +114 -0
- rdf_construct/localise/config.py +508 -0
- rdf_construct/localise/extractor.py +427 -0
- rdf_construct/localise/formatters/__init__.py +36 -0
- rdf_construct/localise/formatters/markdown.py +229 -0
- rdf_construct/localise/formatters/text.py +224 -0
- rdf_construct/localise/merger.py +346 -0
- rdf_construct/localise/reporter.py +356 -0
- rdf_construct/main.py +6 -0
- rdf_construct/merge/__init__.py +165 -0
- rdf_construct/merge/config.py +354 -0
- rdf_construct/merge/conflicts.py +281 -0
- rdf_construct/merge/formatters.py +426 -0
- rdf_construct/merge/merger.py +425 -0
- rdf_construct/merge/migrator.py +339 -0
- rdf_construct/merge/rules.py +377 -0
- rdf_construct/merge/splitter.py +1102 -0
- rdf_construct/puml2rdf/__init__.py +103 -0
- rdf_construct/puml2rdf/config.py +230 -0
- rdf_construct/puml2rdf/converter.py +420 -0
- rdf_construct/puml2rdf/merger.py +200 -0
- rdf_construct/puml2rdf/model.py +202 -0
- rdf_construct/puml2rdf/parser.py +565 -0
- rdf_construct/puml2rdf/validators.py +451 -0
- rdf_construct/refactor/__init__.py +72 -0
- rdf_construct/refactor/config.py +362 -0
- rdf_construct/refactor/deprecator.py +328 -0
- rdf_construct/refactor/formatters/__init__.py +8 -0
- rdf_construct/refactor/formatters/text.py +311 -0
- rdf_construct/refactor/renamer.py +294 -0
- rdf_construct/shacl/__init__.py +56 -0
- rdf_construct/shacl/config.py +166 -0
- rdf_construct/shacl/converters.py +520 -0
- rdf_construct/shacl/generator.py +364 -0
- rdf_construct/shacl/namespaces.py +93 -0
- rdf_construct/stats/__init__.py +29 -0
- rdf_construct/stats/collector.py +178 -0
- rdf_construct/stats/comparator.py +298 -0
- rdf_construct/stats/formatters/__init__.py +83 -0
- rdf_construct/stats/formatters/json.py +38 -0
- rdf_construct/stats/formatters/markdown.py +153 -0
- rdf_construct/stats/formatters/text.py +186 -0
- rdf_construct/stats/metrics/__init__.py +26 -0
- rdf_construct/stats/metrics/basic.py +147 -0
- rdf_construct/stats/metrics/complexity.py +137 -0
- rdf_construct/stats/metrics/connectivity.py +130 -0
- rdf_construct/stats/metrics/documentation.py +128 -0
- rdf_construct/stats/metrics/hierarchy.py +207 -0
- rdf_construct/stats/metrics/properties.py +88 -0
- rdf_construct/uml/__init__.py +22 -0
- rdf_construct/uml/context.py +194 -0
- rdf_construct/uml/mapper.py +371 -0
- rdf_construct/uml/odm_renderer.py +789 -0
- rdf_construct/uml/renderer.py +684 -0
- rdf_construct/uml/uml_layout.py +393 -0
- rdf_construct/uml/uml_style.py +613 -0
- rdf_construct-0.3.0.dist-info/METADATA +496 -0
- rdf_construct-0.3.0.dist-info/RECORD +110 -0
- rdf_construct-0.3.0.dist-info/WHEEL +4 -0
- rdf_construct-0.3.0.dist-info/entry_points.txt +3 -0
- rdf_construct-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""PlantUML parser for class diagram syntax.
|
|
2
|
+
|
|
3
|
+
This module provides regex-based parsing of PlantUML class diagrams,
|
|
4
|
+
extracting classes, relationships, packages, and notes into an
|
|
5
|
+
intermediate model representation.
|
|
6
|
+
|
|
7
|
+
The parser uses a multi-pass approach:
|
|
8
|
+
1. Extract packages and set up namespaces
|
|
9
|
+
2. Extract classes with attributes
|
|
10
|
+
3. Extract relationships
|
|
11
|
+
4. Attach notes to entities
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from rdf_construct.puml2rdf.model import (
|
|
20
|
+
PumlAttribute,
|
|
21
|
+
PumlClass,
|
|
22
|
+
PumlModel,
|
|
23
|
+
PumlNote,
|
|
24
|
+
PumlPackage,
|
|
25
|
+
PumlRelationship,
|
|
26
|
+
RelationshipType,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_dotted_name(dotted: str) -> tuple[Optional[str], str]:
|
|
31
|
+
"""Split a dotted name into package and local name.
|
|
32
|
+
|
|
33
|
+
Examples:
|
|
34
|
+
"building.Building" -> ("building", "Building")
|
|
35
|
+
"ies.ArtificialFeature" -> ("ies", "ArtificialFeature")
|
|
36
|
+
"Building" -> (None, "Building")
|
|
37
|
+
"""
|
|
38
|
+
if "." in dotted:
|
|
39
|
+
parts = dotted.rsplit(".", 1) # Split on last dot
|
|
40
|
+
return (parts[0], parts[1])
|
|
41
|
+
return (None, dotted)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ParseError:
|
|
46
|
+
"""An error encountered during parsing.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
line_number: Line where the error occurred
|
|
50
|
+
message: Description of the error
|
|
51
|
+
line_content: The actual line content
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
line_number: int
|
|
55
|
+
message: str
|
|
56
|
+
line_content: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class ParseResult:
|
|
61
|
+
"""Result of parsing a PlantUML file.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
model: The parsed model (may be partial if errors occurred)
|
|
65
|
+
errors: List of parse errors encountered
|
|
66
|
+
warnings: List of non-fatal warnings
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
model: PumlModel
|
|
70
|
+
errors: list[ParseError]
|
|
71
|
+
warnings: list[str]
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def success(self) -> bool:
|
|
75
|
+
"""Return True if parsing completed without errors."""
|
|
76
|
+
return len(self.errors) == 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PlantUMLParser:
|
|
80
|
+
"""Parser for PlantUML class diagram syntax.
|
|
81
|
+
|
|
82
|
+
Parses PlantUML text and produces a PumlModel containing all
|
|
83
|
+
extracted entities ready for RDF conversion.
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
parser = PlantUMLParser()
|
|
87
|
+
result = parser.parse('''
|
|
88
|
+
@startuml
|
|
89
|
+
class Building {
|
|
90
|
+
floorArea : decimal
|
|
91
|
+
}
|
|
92
|
+
@enduml
|
|
93
|
+
''')
|
|
94
|
+
if result.success:
|
|
95
|
+
model = result.model
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
# ==========================================================================
|
|
99
|
+
# Regex patterns for PlantUML syntax elements
|
|
100
|
+
# ==========================================================================
|
|
101
|
+
|
|
102
|
+
# Package: package "Name" as ns { ... } or package Name { ... }
|
|
103
|
+
PACKAGE_PATTERN = re.compile(
|
|
104
|
+
r'package\s+(?:"([^"]+)"|(\S+))' # Package name (quoted or unquoted)
|
|
105
|
+
r'(?:\s+as\s+(\S+))?' # Optional 'as namespace'
|
|
106
|
+
r'(?:\s*<<(\w+)>>)?' # Optional stereotype
|
|
107
|
+
r'\s*\{', # Opening brace
|
|
108
|
+
re.MULTILINE,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Class declaration: class Name <<stereotype>> { ... } or class "Name" { ... }
|
|
112
|
+
CLASS_PATTERN = re.compile(
|
|
113
|
+
r'(?:^|\n)\s*'
|
|
114
|
+
r'(abstract\s+)?' # Group 1: Optional abstract keyword
|
|
115
|
+
r'class\s+'
|
|
116
|
+
r'(?:'
|
|
117
|
+
r'"([^"]+)"\s+as\s+([\w.]+)' # Groups 2,3: "Display Name" as alias
|
|
118
|
+
r'|'
|
|
119
|
+
r'"([^"]+)"' # Group 4: Just "Quoted Name"
|
|
120
|
+
r'|'
|
|
121
|
+
r'([\w.]+)' # Group 5: Unquoted name (may include dots)
|
|
122
|
+
r')'
|
|
123
|
+
r'(?:\s*<<(\w+)>>)?' # Group 6: Optional stereotype
|
|
124
|
+
r'(?:\s*#[^\s{]*)?' # Optional styling - ignore
|
|
125
|
+
r'[ \t]*(?:\{([^}]*)\})?', # Group 7: Optional body
|
|
126
|
+
re.MULTILINE | re.DOTALL,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Attribute: visibility name : type
|
|
130
|
+
ATTRIBUTE_PATTERN = re.compile(
|
|
131
|
+
r'^\s*'
|
|
132
|
+
r'([+\-#~])?\s*' # Optional visibility
|
|
133
|
+
r'(\{static\})?\s*' # Optional static marker
|
|
134
|
+
r'(\w+)' # Attribute name
|
|
135
|
+
r'(?:\s*:\s*(\w+))?' # Optional : type
|
|
136
|
+
r'\s*$',
|
|
137
|
+
re.MULTILINE,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Relationships - various arrow styles
|
|
141
|
+
# A --|> B (inheritance)
|
|
142
|
+
# A --> B : label (association)
|
|
143
|
+
# A "1" --> "*" B : label (with cardinalities)
|
|
144
|
+
# A o-- B (aggregation)
|
|
145
|
+
# A *-- B (composition)
|
|
146
|
+
RELATIONSHIP_PATTERN = re.compile(
|
|
147
|
+
r'(?:^|\n)\s*'
|
|
148
|
+
r'(?:"([^"]+)"|(\w+))' # Source class (quoted or not)
|
|
149
|
+
r'\s*'
|
|
150
|
+
r'(?:"([^"]*)")?\s*' # Optional source cardinality
|
|
151
|
+
r'([o*])?' # Optional aggregation/composition marker at source
|
|
152
|
+
r'(--?|\.\.)' # Line style (-- or ..)
|
|
153
|
+
r'([|>o*])?' # Arrow head or aggregation marker at target
|
|
154
|
+
r'\s*'
|
|
155
|
+
r'(?:"([^"]*)")?\s*' # Optional target cardinality
|
|
156
|
+
r'(?:"([^"]+)"|(\w+))' # Target class (quoted or not)
|
|
157
|
+
r'(?:\s*:\s*([^\n]+))?', # Optional label
|
|
158
|
+
re.MULTILINE,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Note attached to element: note right of Class : text
|
|
162
|
+
NOTE_ATTACHED_PATTERN = re.compile(
|
|
163
|
+
r'note\s+(right|left|top|bottom)\s+of\s+(\w+)\s*'
|
|
164
|
+
r'(?::\s*([^\n]+))?' # Single line note
|
|
165
|
+
r'|'
|
|
166
|
+
r'note\s+(right|left|top|bottom)\s+of\s+(\w+)\s*\n'
|
|
167
|
+
r'(.*?)' # Multi-line content
|
|
168
|
+
r'\s*end\s*note',
|
|
169
|
+
re.MULTILINE | re.DOTALL,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Standalone note block: note "text" as N1
|
|
173
|
+
NOTE_STANDALONE_PATTERN = re.compile(
|
|
174
|
+
r'note\s+"([^"]+)"\s+as\s+(\w+)', re.MULTILINE
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Diagram title
|
|
178
|
+
TITLE_PATTERN = re.compile(r'title\s+(.+?)(?:\n|$)', re.MULTILINE)
|
|
179
|
+
|
|
180
|
+
# Skinparam settings
|
|
181
|
+
SKINPARAM_PATTERN = re.compile(
|
|
182
|
+
r'skinparam\s+(\w+)\s+(\S+)', re.MULTILINE
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def __init__(self) -> None:
|
|
186
|
+
"""Initialise the parser."""
|
|
187
|
+
self._errors: list[ParseError] = []
|
|
188
|
+
self._warnings: list[str] = []
|
|
189
|
+
self._current_package: Optional[str] = None
|
|
190
|
+
self._package_stack: list[str] = []
|
|
191
|
+
|
|
192
|
+
def parse(self, content: str) -> ParseResult:
|
|
193
|
+
"""Parse PlantUML content into a model.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
content: PlantUML diagram text
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
ParseResult containing the model and any errors/warnings
|
|
200
|
+
"""
|
|
201
|
+
self._errors = []
|
|
202
|
+
self._warnings = []
|
|
203
|
+
|
|
204
|
+
model = PumlModel()
|
|
205
|
+
|
|
206
|
+
# Strip @startuml / @enduml
|
|
207
|
+
content = self._strip_diagram_markers(content)
|
|
208
|
+
|
|
209
|
+
# Pass 1: Extract packages and namespaces
|
|
210
|
+
model.packages = self._parse_packages(content)
|
|
211
|
+
|
|
212
|
+
# Pass 2: Extract classes with attributes
|
|
213
|
+
model.classes = self._parse_classes(content)
|
|
214
|
+
|
|
215
|
+
# Pass 3: Extract relationships
|
|
216
|
+
model.relationships = self._parse_relationships(content)
|
|
217
|
+
|
|
218
|
+
# Pass 4: Extract and attach notes
|
|
219
|
+
notes = self._parse_notes(content)
|
|
220
|
+
self._attach_notes(model, notes)
|
|
221
|
+
model.notes = [n for n in notes if n.attached_to is None]
|
|
222
|
+
|
|
223
|
+
# Extract title
|
|
224
|
+
model.title = self._parse_title(content)
|
|
225
|
+
|
|
226
|
+
# Extract skinparams
|
|
227
|
+
model.skin_params = self._parse_skinparams(content)
|
|
228
|
+
|
|
229
|
+
return ParseResult(model=model, errors=self._errors, warnings=self._warnings)
|
|
230
|
+
|
|
231
|
+
def parse_file(self, path: Path) -> ParseResult:
|
|
232
|
+
"""Parse a PlantUML file.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
path: Path to the .puml file
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
ParseResult containing the model and any errors/warnings
|
|
239
|
+
"""
|
|
240
|
+
content = path.read_text(encoding="utf-8")
|
|
241
|
+
return self.parse(content)
|
|
242
|
+
|
|
243
|
+
def _strip_diagram_markers(self, content: str) -> str:
|
|
244
|
+
"""Remove @startuml and @enduml markers."""
|
|
245
|
+
# Remove @startuml (with optional name)
|
|
246
|
+
content = re.sub(r'@startuml\s*(?:\([^)]*\))?\s*\n?', '', content)
|
|
247
|
+
# Remove @enduml
|
|
248
|
+
content = re.sub(r'@enduml\s*', '', content)
|
|
249
|
+
return content
|
|
250
|
+
|
|
251
|
+
def _parse_packages(self, content: str) -> list[PumlPackage]:
|
|
252
|
+
"""Extract package definitions from content."""
|
|
253
|
+
packages = []
|
|
254
|
+
|
|
255
|
+
for match in self.PACKAGE_PATTERN.finditer(content):
|
|
256
|
+
name = match.group(1) or match.group(2) # Quoted or unquoted
|
|
257
|
+
namespace_uri = match.group(3) # From 'as' clause
|
|
258
|
+
stereotype = match.group(4)
|
|
259
|
+
|
|
260
|
+
# If namespace looks like a URI, use it; otherwise treat as prefix
|
|
261
|
+
if namespace_uri and not namespace_uri.startswith("http"):
|
|
262
|
+
# It's a prefix alias like 'bld', not a full URI
|
|
263
|
+
namespace_uri = None
|
|
264
|
+
|
|
265
|
+
packages.append(
|
|
266
|
+
PumlPackage(
|
|
267
|
+
name=name,
|
|
268
|
+
namespace_uri=namespace_uri,
|
|
269
|
+
stereotype=stereotype,
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return packages
|
|
274
|
+
|
|
275
|
+
def _parse_classes(self, content: str) -> list[PumlClass]:
|
|
276
|
+
"""Extract class definitions from content."""
|
|
277
|
+
classes = []
|
|
278
|
+
package_map = self._build_package_map(content)
|
|
279
|
+
|
|
280
|
+
for match in self.CLASS_PATTERN.finditer(content):
|
|
281
|
+
is_abstract = match.group(1) is not None
|
|
282
|
+
display_name = None
|
|
283
|
+
|
|
284
|
+
if match.group(3): # "Display Name" as alias pattern
|
|
285
|
+
display_name = match.group(2) # "Building"
|
|
286
|
+
alias = match.group(3) # "building.Building"
|
|
287
|
+
package, name = parse_dotted_name(alias)
|
|
288
|
+
elif match.group(4): # Just "Quoted Name"
|
|
289
|
+
package, name = None, match.group(4)
|
|
290
|
+
else: # Unquoted name (group 5), may be dotted
|
|
291
|
+
package, name = parse_dotted_name(match.group(5))
|
|
292
|
+
|
|
293
|
+
stereotype = match.group(6)
|
|
294
|
+
body = match.group(7) or ""
|
|
295
|
+
|
|
296
|
+
# Parse attributes from body
|
|
297
|
+
attributes = self._parse_attributes(body)
|
|
298
|
+
|
|
299
|
+
# Package from dotted name takes precedence over positional package
|
|
300
|
+
if package is None:
|
|
301
|
+
pos = match.start()
|
|
302
|
+
package = self._find_package_at_position(pos, package_map)
|
|
303
|
+
|
|
304
|
+
classes.append(
|
|
305
|
+
PumlClass(
|
|
306
|
+
name=name,
|
|
307
|
+
package=package,
|
|
308
|
+
stereotype=stereotype,
|
|
309
|
+
attributes=attributes,
|
|
310
|
+
is_abstract=is_abstract or stereotype == "abstract",
|
|
311
|
+
display_name=display_name,
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return classes
|
|
316
|
+
|
|
317
|
+
def _parse_attributes(self, body: str) -> list[PumlAttribute]:
|
|
318
|
+
"""Extract attributes from a class body."""
|
|
319
|
+
attributes = []
|
|
320
|
+
|
|
321
|
+
for line in body.strip().split("\n"):
|
|
322
|
+
line = line.strip()
|
|
323
|
+
if not line or line.startswith("--"):
|
|
324
|
+
continue # Skip separators
|
|
325
|
+
|
|
326
|
+
match = self.ATTRIBUTE_PATTERN.match(line)
|
|
327
|
+
if match:
|
|
328
|
+
visibility = match.group(1) or "+"
|
|
329
|
+
is_static = match.group(2) is not None
|
|
330
|
+
name = match.group(3)
|
|
331
|
+
datatype = match.group(4)
|
|
332
|
+
|
|
333
|
+
attributes.append(
|
|
334
|
+
PumlAttribute(
|
|
335
|
+
name=name,
|
|
336
|
+
datatype=datatype,
|
|
337
|
+
visibility=visibility,
|
|
338
|
+
is_static=is_static,
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
else:
|
|
342
|
+
# Try simpler pattern: just "name" or "name : type"
|
|
343
|
+
simple_match = re.match(r'(\w+)(?:\s*:\s*(\w+))?', line)
|
|
344
|
+
if simple_match:
|
|
345
|
+
attributes.append(
|
|
346
|
+
PumlAttribute(
|
|
347
|
+
name=simple_match.group(1),
|
|
348
|
+
datatype=simple_match.group(2),
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
return attributes
|
|
353
|
+
|
|
354
|
+
def _parse_relationships(self, content: str) -> list[PumlRelationship]:
|
|
355
|
+
"""Extract relationship definitions from content."""
|
|
356
|
+
relationships = []
|
|
357
|
+
|
|
358
|
+
# Pattern for inheritance with direction hints
|
|
359
|
+
inheritance_pattern = re.compile(
|
|
360
|
+
r'(?:^|\n)\s*'
|
|
361
|
+
r'([\w.]+)' # Source (dotted names)
|
|
362
|
+
r'\s*'
|
|
363
|
+
r'(<\|)?' # Left arrow head
|
|
364
|
+
r'(-[udlr]?-)' # Line with optional direction
|
|
365
|
+
r'(\|>)?' # Right arrow head
|
|
366
|
+
r'\s*'
|
|
367
|
+
r'([\w.]+)', # Target (dotted names)
|
|
368
|
+
re.MULTILINE,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
for match in inheritance_pattern.finditer(content):
|
|
372
|
+
source_full = match.group(1)
|
|
373
|
+
left_head = match.group(2)
|
|
374
|
+
right_head = match.group(4)
|
|
375
|
+
target_full = match.group(5)
|
|
376
|
+
|
|
377
|
+
# Split into package.name - store qualified name for lookup
|
|
378
|
+
if left_head and not right_head:
|
|
379
|
+
# <|-- pattern: target extends source
|
|
380
|
+
relationships.append(
|
|
381
|
+
PumlRelationship(
|
|
382
|
+
source=target_full, # Keep full qualified name for lookup
|
|
383
|
+
target=source_full,
|
|
384
|
+
rel_type=RelationshipType.INHERITANCE,
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
elif right_head and not left_head:
|
|
388
|
+
# --|> pattern: source extends target
|
|
389
|
+
relationships.append(
|
|
390
|
+
PumlRelationship(
|
|
391
|
+
source=source_full,
|
|
392
|
+
target=target_full,
|
|
393
|
+
rel_type=RelationshipType.INHERITANCE,
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Association pattern (unchanged but now with --)
|
|
398
|
+
assoc_pattern = re.compile(
|
|
399
|
+
r'(?:^|\n)\s*'
|
|
400
|
+
r'([\w.]+)'
|
|
401
|
+
r'\s*'
|
|
402
|
+
r'(?:"([^"]*)")?\s*'
|
|
403
|
+
r'([o*])?'
|
|
404
|
+
r'--' # Require two dashes
|
|
405
|
+
r'([o*>])?'
|
|
406
|
+
r'\s*'
|
|
407
|
+
r'(?:"([^"]*)")?\s*'
|
|
408
|
+
r'([\w.]+)'
|
|
409
|
+
r'(?:\s*:\s*([^\n]+))?',
|
|
410
|
+
re.MULTILINE,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
for match in assoc_pattern.finditer(content):
|
|
414
|
+
source = match.group(1)
|
|
415
|
+
source_card = match.group(2)
|
|
416
|
+
source_marker = match.group(3)
|
|
417
|
+
target_marker = match.group(4)
|
|
418
|
+
target_card = match.group(5)
|
|
419
|
+
target = match.group(6)
|
|
420
|
+
label = match.group(7)
|
|
421
|
+
|
|
422
|
+
# Skip if this looks like inheritance (already handled)
|
|
423
|
+
if "|" in str(target_marker) or "|" in str(source_marker):
|
|
424
|
+
continue
|
|
425
|
+
|
|
426
|
+
# Determine relationship type
|
|
427
|
+
if source_marker == "*" or target_marker == "*":
|
|
428
|
+
rel_type = RelationshipType.COMPOSITION
|
|
429
|
+
elif source_marker == "o" or target_marker == "o":
|
|
430
|
+
rel_type = RelationshipType.AGGREGATION
|
|
431
|
+
else:
|
|
432
|
+
rel_type = RelationshipType.ASSOCIATION
|
|
433
|
+
|
|
434
|
+
if label:
|
|
435
|
+
label = label.strip()
|
|
436
|
+
|
|
437
|
+
relationships.append(
|
|
438
|
+
PumlRelationship(
|
|
439
|
+
source=source,
|
|
440
|
+
target=target,
|
|
441
|
+
rel_type=rel_type,
|
|
442
|
+
label=label,
|
|
443
|
+
source_cardinality=source_card,
|
|
444
|
+
target_cardinality=target_card,
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
return relationships
|
|
449
|
+
|
|
450
|
+
def _parse_notes(self, content: str) -> list[PumlNote]:
|
|
451
|
+
"""Extract note definitions from content."""
|
|
452
|
+
notes = []
|
|
453
|
+
|
|
454
|
+
# Multi-line notes: note right of Class\n...\nend note
|
|
455
|
+
multiline_pattern = re.compile(
|
|
456
|
+
r'note\s+(right|left|top|bottom)\s+of\s+(\w+)\s*\n'
|
|
457
|
+
r'(.*?)'
|
|
458
|
+
r'\n\s*end\s*note',
|
|
459
|
+
re.MULTILINE | re.DOTALL,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
for match in multiline_pattern.finditer(content):
|
|
463
|
+
position = match.group(1)
|
|
464
|
+
attached_to = match.group(2)
|
|
465
|
+
text = match.group(3).strip()
|
|
466
|
+
|
|
467
|
+
notes.append(
|
|
468
|
+
PumlNote(
|
|
469
|
+
content=text,
|
|
470
|
+
attached_to=attached_to,
|
|
471
|
+
position=position,
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Single-line notes: note right of Class : text
|
|
476
|
+
inline_pattern = re.compile(
|
|
477
|
+
r'note\s+(right|left|top|bottom)\s+of\s+(\w+)\s*:\s*([^\n]+)',
|
|
478
|
+
re.MULTILINE,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
for match in inline_pattern.finditer(content):
|
|
482
|
+
position = match.group(1)
|
|
483
|
+
attached_to = match.group(2)
|
|
484
|
+
text = match.group(3).strip()
|
|
485
|
+
|
|
486
|
+
notes.append(
|
|
487
|
+
PumlNote(
|
|
488
|
+
content=text,
|
|
489
|
+
attached_to=attached_to,
|
|
490
|
+
position=position,
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
return notes
|
|
495
|
+
|
|
496
|
+
def _attach_notes(self, model: PumlModel, notes: list[PumlNote]) -> None:
|
|
497
|
+
"""Attach notes to their referenced classes."""
|
|
498
|
+
for note in notes:
|
|
499
|
+
if note.attached_to:
|
|
500
|
+
cls = model.get_class(note.attached_to)
|
|
501
|
+
if cls:
|
|
502
|
+
cls.note = note.content
|
|
503
|
+
else:
|
|
504
|
+
self._warnings.append(
|
|
505
|
+
f"Note attached to unknown class: {note.attached_to}"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
def _parse_title(self, content: str) -> Optional[str]:
|
|
509
|
+
"""Extract diagram title."""
|
|
510
|
+
match = self.TITLE_PATTERN.search(content)
|
|
511
|
+
if match:
|
|
512
|
+
return match.group(1).strip()
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
def _parse_skinparams(self, content: str) -> dict[str, str]:
|
|
516
|
+
"""Extract skinparam settings."""
|
|
517
|
+
params = {}
|
|
518
|
+
for match in self.SKINPARAM_PATTERN.finditer(content):
|
|
519
|
+
key = match.group(1)
|
|
520
|
+
value = match.group(2)
|
|
521
|
+
params[key] = value
|
|
522
|
+
return params
|
|
523
|
+
|
|
524
|
+
def _build_package_map(self, content: str) -> list[tuple[int, int, str]]:
|
|
525
|
+
"""Build a map of character positions to package names.
|
|
526
|
+
|
|
527
|
+
Returns a list of (start, end, package_name) tuples.
|
|
528
|
+
"""
|
|
529
|
+
package_map = []
|
|
530
|
+
|
|
531
|
+
# Find all package blocks with their content
|
|
532
|
+
pattern = re.compile(
|
|
533
|
+
r'package\s+(?:"([^"]+)"|(\S+))'
|
|
534
|
+
r'(?:\s+as\s+\S+)?'
|
|
535
|
+
r'(?:\s*<<\w+>>)?'
|
|
536
|
+
r'\s*\{',
|
|
537
|
+
re.MULTILINE,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
for match in pattern.finditer(content):
|
|
541
|
+
package_name = match.group(1) or match.group(2)
|
|
542
|
+
start = match.end()
|
|
543
|
+
|
|
544
|
+
# Find matching closing brace
|
|
545
|
+
brace_count = 1
|
|
546
|
+
pos = start
|
|
547
|
+
while pos < len(content) and brace_count > 0:
|
|
548
|
+
if content[pos] == "{":
|
|
549
|
+
brace_count += 1
|
|
550
|
+
elif content[pos] == "}":
|
|
551
|
+
brace_count -= 1
|
|
552
|
+
pos += 1
|
|
553
|
+
|
|
554
|
+
package_map.append((start, pos - 1, package_name))
|
|
555
|
+
|
|
556
|
+
return package_map
|
|
557
|
+
|
|
558
|
+
def _find_package_at_position(
|
|
559
|
+
self, pos: int, package_map: list[tuple[int, int, str]]
|
|
560
|
+
) -> Optional[str]:
|
|
561
|
+
"""Find which package contains a given character position."""
|
|
562
|
+
for start, end, name in package_map:
|
|
563
|
+
if start <= pos <= end:
|
|
564
|
+
return name
|
|
565
|
+
return None
|