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.
Files changed (110) hide show
  1. rdf_construct/__init__.py +12 -0
  2. rdf_construct/__main__.py +0 -0
  3. rdf_construct/cli.py +3429 -0
  4. rdf_construct/core/__init__.py +33 -0
  5. rdf_construct/core/config.py +116 -0
  6. rdf_construct/core/ordering.py +219 -0
  7. rdf_construct/core/predicate_order.py +212 -0
  8. rdf_construct/core/profile.py +157 -0
  9. rdf_construct/core/selector.py +64 -0
  10. rdf_construct/core/serialiser.py +232 -0
  11. rdf_construct/core/utils.py +89 -0
  12. rdf_construct/cq/__init__.py +77 -0
  13. rdf_construct/cq/expectations.py +365 -0
  14. rdf_construct/cq/formatters/__init__.py +45 -0
  15. rdf_construct/cq/formatters/json.py +104 -0
  16. rdf_construct/cq/formatters/junit.py +104 -0
  17. rdf_construct/cq/formatters/text.py +146 -0
  18. rdf_construct/cq/loader.py +300 -0
  19. rdf_construct/cq/runner.py +321 -0
  20. rdf_construct/diff/__init__.py +59 -0
  21. rdf_construct/diff/change_types.py +214 -0
  22. rdf_construct/diff/comparator.py +338 -0
  23. rdf_construct/diff/filters.py +133 -0
  24. rdf_construct/diff/formatters/__init__.py +71 -0
  25. rdf_construct/diff/formatters/json.py +192 -0
  26. rdf_construct/diff/formatters/markdown.py +210 -0
  27. rdf_construct/diff/formatters/text.py +195 -0
  28. rdf_construct/docs/__init__.py +60 -0
  29. rdf_construct/docs/config.py +238 -0
  30. rdf_construct/docs/extractors.py +603 -0
  31. rdf_construct/docs/generator.py +360 -0
  32. rdf_construct/docs/renderers/__init__.py +7 -0
  33. rdf_construct/docs/renderers/html.py +803 -0
  34. rdf_construct/docs/renderers/json.py +390 -0
  35. rdf_construct/docs/renderers/markdown.py +628 -0
  36. rdf_construct/docs/search.py +278 -0
  37. rdf_construct/docs/templates/html/base.html.jinja +44 -0
  38. rdf_construct/docs/templates/html/class.html.jinja +152 -0
  39. rdf_construct/docs/templates/html/hierarchy.html.jinja +28 -0
  40. rdf_construct/docs/templates/html/index.html.jinja +110 -0
  41. rdf_construct/docs/templates/html/instance.html.jinja +90 -0
  42. rdf_construct/docs/templates/html/namespaces.html.jinja +37 -0
  43. rdf_construct/docs/templates/html/property.html.jinja +124 -0
  44. rdf_construct/docs/templates/html/single_page.html.jinja +169 -0
  45. rdf_construct/lint/__init__.py +75 -0
  46. rdf_construct/lint/config.py +214 -0
  47. rdf_construct/lint/engine.py +396 -0
  48. rdf_construct/lint/formatters.py +327 -0
  49. rdf_construct/lint/rules.py +692 -0
  50. rdf_construct/localise/__init__.py +114 -0
  51. rdf_construct/localise/config.py +508 -0
  52. rdf_construct/localise/extractor.py +427 -0
  53. rdf_construct/localise/formatters/__init__.py +36 -0
  54. rdf_construct/localise/formatters/markdown.py +229 -0
  55. rdf_construct/localise/formatters/text.py +224 -0
  56. rdf_construct/localise/merger.py +346 -0
  57. rdf_construct/localise/reporter.py +356 -0
  58. rdf_construct/main.py +6 -0
  59. rdf_construct/merge/__init__.py +165 -0
  60. rdf_construct/merge/config.py +354 -0
  61. rdf_construct/merge/conflicts.py +281 -0
  62. rdf_construct/merge/formatters.py +426 -0
  63. rdf_construct/merge/merger.py +425 -0
  64. rdf_construct/merge/migrator.py +339 -0
  65. rdf_construct/merge/rules.py +377 -0
  66. rdf_construct/merge/splitter.py +1102 -0
  67. rdf_construct/puml2rdf/__init__.py +103 -0
  68. rdf_construct/puml2rdf/config.py +230 -0
  69. rdf_construct/puml2rdf/converter.py +420 -0
  70. rdf_construct/puml2rdf/merger.py +200 -0
  71. rdf_construct/puml2rdf/model.py +202 -0
  72. rdf_construct/puml2rdf/parser.py +565 -0
  73. rdf_construct/puml2rdf/validators.py +451 -0
  74. rdf_construct/refactor/__init__.py +72 -0
  75. rdf_construct/refactor/config.py +362 -0
  76. rdf_construct/refactor/deprecator.py +328 -0
  77. rdf_construct/refactor/formatters/__init__.py +8 -0
  78. rdf_construct/refactor/formatters/text.py +311 -0
  79. rdf_construct/refactor/renamer.py +294 -0
  80. rdf_construct/shacl/__init__.py +56 -0
  81. rdf_construct/shacl/config.py +166 -0
  82. rdf_construct/shacl/converters.py +520 -0
  83. rdf_construct/shacl/generator.py +364 -0
  84. rdf_construct/shacl/namespaces.py +93 -0
  85. rdf_construct/stats/__init__.py +29 -0
  86. rdf_construct/stats/collector.py +178 -0
  87. rdf_construct/stats/comparator.py +298 -0
  88. rdf_construct/stats/formatters/__init__.py +83 -0
  89. rdf_construct/stats/formatters/json.py +38 -0
  90. rdf_construct/stats/formatters/markdown.py +153 -0
  91. rdf_construct/stats/formatters/text.py +186 -0
  92. rdf_construct/stats/metrics/__init__.py +26 -0
  93. rdf_construct/stats/metrics/basic.py +147 -0
  94. rdf_construct/stats/metrics/complexity.py +137 -0
  95. rdf_construct/stats/metrics/connectivity.py +130 -0
  96. rdf_construct/stats/metrics/documentation.py +128 -0
  97. rdf_construct/stats/metrics/hierarchy.py +207 -0
  98. rdf_construct/stats/metrics/properties.py +88 -0
  99. rdf_construct/uml/__init__.py +22 -0
  100. rdf_construct/uml/context.py +194 -0
  101. rdf_construct/uml/mapper.py +371 -0
  102. rdf_construct/uml/odm_renderer.py +789 -0
  103. rdf_construct/uml/renderer.py +684 -0
  104. rdf_construct/uml/uml_layout.py +393 -0
  105. rdf_construct/uml/uml_style.py +613 -0
  106. rdf_construct-0.3.0.dist-info/METADATA +496 -0
  107. rdf_construct-0.3.0.dist-info/RECORD +110 -0
  108. rdf_construct-0.3.0.dist-info/WHEEL +4 -0
  109. rdf_construct-0.3.0.dist-info/entry_points.txt +3 -0
  110. 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