statable 3.1.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 (117) hide show
  1. codegen/__init__.py +38 -0
  2. codegen/c_code_generator.py +1752 -0
  3. codegen/code_merger.py +371 -0
  4. codegen/code_templates.py +509 -0
  5. codegen/config.py +167 -0
  6. codegen/enum_generator.py +377 -0
  7. codegen/event_queue_generator.py +303 -0
  8. codegen/interrupt_generator.py +345 -0
  9. codegen/naming_convention.py +171 -0
  10. codegen/osal_generator.py +282 -0
  11. codegen/role_function_generator.py +1244 -0
  12. codegen/sample_data.py +269 -0
  13. codegen/state_actions_generator.py +395 -0
  14. codegen/struct_generator.py +721 -0
  15. codegen/timer_generator.py +299 -0
  16. codegen/transition_generator.py +907 -0
  17. codegen/type_mapper.py +69 -0
  18. codegen/validate/__init__.py +38 -0
  19. codegen/validate/change_actions.py +90 -0
  20. codegen/validate/change_applier.py +416 -0
  21. codegen/validate/clipboard_manager.py +47 -0
  22. codegen/validate/data/__init__.py +30 -0
  23. codegen/validate/data/action_definitions.py +200 -0
  24. codegen/validate/data/keywords.py +46 -0
  25. codegen/validate/data/prompt_templates.py +176 -0
  26. codegen/validate/data/validation_rules.py +205 -0
  27. codegen/validate/items/__init__.py +30 -0
  28. codegen/validate/items/base_validator.py +25 -0
  29. codegen/validate/items/cell_validator.py +275 -0
  30. codegen/validate/items/custom_type_validator.py +75 -0
  31. codegen/validate/items/event_validator.py +78 -0
  32. codegen/validate/items/flag_validator.py +74 -0
  33. codegen/validate/items/interrupt_validator.py +75 -0
  34. codegen/validate/items/queue_validator.py +73 -0
  35. codegen/validate/items/role_function_validator.py +175 -0
  36. codegen/validate/items/state_validator.py +88 -0
  37. codegen/validate/items/timer_validator.py +120 -0
  38. codegen/validate/items/transition_validator.py +113 -0
  39. codegen/validate/items/variable_validator.py +132 -0
  40. codegen/validate/logger.py +52 -0
  41. codegen/validate/models.py +177 -0
  42. codegen/validate/prompt_generator.py +724 -0
  43. codegen/validate/response_parser.py +154 -0
  44. codegen/validate/response_validator.py +275 -0
  45. codegen/validate/validation_dialog.py +322 -0
  46. codegen/validate/validator.py +94 -0
  47. codegen/variable_generator.py +419 -0
  48. statable/__init__.py +71 -0
  49. statable/__main__.py +49 -0
  50. statable/cli.py +251 -0
  51. statable/global_defs.py +256 -0
  52. statable/mermaid_gen.py +144 -0
  53. statable/model.py +426 -0
  54. statable/parser.py +64 -0
  55. statable/sample_data.py +435 -0
  56. statable/shared/__init__.py +24 -0
  57. statable/shared/condition_library.py +63 -0
  58. statable/shared/literal_library.py +66 -0
  59. statable/shared/role_function_library.py +150 -0
  60. statable/state_machine.py +121 -0
  61. statable/xml_io.py +1164 -0
  62. statable-3.1.0.dist-info/METADATA +151 -0
  63. statable-3.1.0.dist-info/RECORD +117 -0
  64. statable-3.1.0.dist-info/WHEEL +5 -0
  65. statable-3.1.0.dist-info/entry_points.txt +2 -0
  66. statable-3.1.0.dist-info/top_level.txt +3 -0
  67. statable_gui/__init__.py +23 -0
  68. statable_gui/action_edit_dialog.py +214 -0
  69. statable_gui/code_generation_dialog.py +486 -0
  70. statable_gui/code_generation_settings_dialog.py +522 -0
  71. statable_gui/common_widgets.py +537 -0
  72. statable_gui/condition_builder_dialog.py +500 -0
  73. statable_gui/condition_edit_dialog.py +123 -0
  74. statable_gui/condition_template_dialog.py +58 -0
  75. statable_gui/config.py +21 -0
  76. statable_gui/dialogs.py +439 -0
  77. statable_gui/event_definition_dialog.py +605 -0
  78. statable_gui/event_delivery_settings_dialog.py +191 -0
  79. statable_gui/event_queue_dialog.py +245 -0
  80. statable_gui/global_defs.py +167 -0
  81. statable_gui/global_defs_dialog.py +796 -0
  82. statable_gui/i18n/__init__.py +118 -0
  83. statable_gui/interrupt_handler_edit_dialog.py +810 -0
  84. statable_gui/layer_settings_dialog.py +180 -0
  85. statable_gui/libcntrl/__init__.py +23 -0
  86. statable_gui/libcntrl/condition_library.py +13 -0
  87. statable_gui/libcntrl/literal_library.py +13 -0
  88. statable_gui/libcntrl/literal_management_dialog.py +185 -0
  89. statable_gui/libcntrl/role_function_edit_dialog.py +145 -0
  90. statable_gui/libcntrl/role_function_library.py +13 -0
  91. statable_gui/literal_definition_dialog.py +62 -0
  92. statable_gui/logger.py +83 -0
  93. statable_gui/main_window.py +1380 -0
  94. statable_gui/matrix_table.py +413 -0
  95. statable_gui/preference_keys.py +18 -0
  96. statable_gui/preferences.py +74 -0
  97. statable_gui/role_function_dialog.py +244 -0
  98. statable_gui/sample_data.py +11 -0
  99. statable_gui/state_actions_dialog.py +398 -0
  100. statable_gui/symbol_picker.py +170 -0
  101. statable_gui/traceball.py +37 -0
  102. statable_gui/transition_editor_direct/__init__.py +15 -0
  103. statable_gui/transition_editor_direct/actions_tab.py +856 -0
  104. statable_gui/transition_editor_direct/code_widget.py +344 -0
  105. statable_gui/transition_editor_direct/coverage_analyzer.py +163 -0
  106. statable_gui/transition_editor_direct/dialog.py +335 -0
  107. statable_gui/transition_editor_direct/draft.py +270 -0
  108. statable_gui/transition_editor_direct/edit_dialogs.py +160 -0
  109. statable_gui/transition_editor_direct/overview_tab.py +173 -0
  110. statable_gui/transition_editor_direct/palette_widget.py +233 -0
  111. statable_gui/transition_editor_direct/relations_edit_dialog.py +156 -0
  112. statable_gui/transition_editor_direct/relations_tab.py +230 -0
  113. statable_gui/transition_editor_direct/system_global_dialog.py +52 -0
  114. statable_gui/transition_editor_direct/transition_actions_dialog.py +109 -0
  115. statable_gui/transition_editor_direct/transitions_tab.py +620 -0
  116. statable_gui/validation_dialog.py +285 -0
  117. statable_gui/widgets.py +1315 -0
statable/cli.py ADDED
@@ -0,0 +1,251 @@
1
+ # code/statable/cli.py
2
+ """statable-cli - Command-line interface for StaTable SDK.
3
+
4
+ Subcommands:
5
+ generate Generate C code from a design XML file
6
+ validate Validate a design XML file
7
+ version Print version
8
+
9
+ Output:
10
+ stdout: JSON result (default) or plain text
11
+ stderr: log / error messages
12
+
13
+ Exit codes:
14
+ 0 success
15
+ 1 validation error (validate --exit-on-error)
16
+ 2 internal error (missing file, parse failure, etc.)
17
+
18
+ Usage:
19
+ statable-cli generate --xml design.xml --out generated/ --format json
20
+ statable-cli validate --xml design.xml --format json --exit-on-error
21
+ statable-cli version
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ CLI_VERSION = "3.1.0"
31
+
32
+
33
+ def _err(msg: str) -> None:
34
+ print(msg, file=sys.stderr)
35
+
36
+
37
+ def _load_project(xml_path: str):
38
+ """Load XML design file.
39
+
40
+ Returns (tabs, gd, rf_lib, cond_lib, lit_lib, settings).
41
+ """
42
+ from statable.xml_io import project_from_xml
43
+ return project_from_xml(xml_path)
44
+
45
+
46
+ def _build_config(settings):
47
+ """Build CodeGenerationConfig from project settings dict."""
48
+ from codegen.config import CodeGenerationConfig
49
+ cfg = CodeGenerationConfig()
50
+ if not settings:
51
+ return cfg
52
+ for key, value in settings.items():
53
+ if key == "project_name":
54
+ continue
55
+ if hasattr(cfg, key):
56
+ try:
57
+ setattr(cfg, key, value)
58
+ except Exception:
59
+ pass
60
+ return cfg
61
+
62
+
63
+ def cmd_generate(args) -> int:
64
+ xml_path = args.xml
65
+ out_dir = args.out
66
+
67
+ try:
68
+ tabs, gd, rf_lib, _cond, _lit, settings = _load_project(xml_path)
69
+ except FileNotFoundError:
70
+ _err(f"error: XML file not found: {xml_path}")
71
+ return 2
72
+ except Exception as e:
73
+ _err(f"error: failed to load project: {e!r}")
74
+ return 2
75
+
76
+ if not tabs:
77
+ _err("error: no tabs/state machines found in project")
78
+ return 2
79
+
80
+ try:
81
+ cfg = _build_config(settings)
82
+ except Exception as e:
83
+ _err(f"error: config setup failed: {e!r}")
84
+ return 2
85
+
86
+ try:
87
+ from codegen.c_code_generator import CCodeGenerator
88
+ gen = CCodeGenerator(config=cfg)
89
+ generated = gen.generate_all_layers(
90
+ tabs, gd, role_function_library=rf_lib,
91
+ )
92
+ except Exception as e:
93
+ _err(f"error: generation failed: {e!r}")
94
+ return 2
95
+
96
+ try:
97
+ Path(out_dir).mkdir(parents=True, exist_ok=True)
98
+ saved = gen.save_generated_code(generated, str(out_dir))
99
+ except Exception as e:
100
+ _err(f"error: save failed: {e!r}")
101
+ return 2
102
+
103
+ out_res = Path(out_dir).resolve()
104
+ rel_files = []
105
+ for p in saved:
106
+ try:
107
+ rel_files.append(
108
+ Path(p).resolve().relative_to(out_res).as_posix()
109
+ )
110
+ except ValueError:
111
+ rel_files.append(Path(p).name)
112
+ rel_files.sort()
113
+
114
+ if args.format == "json":
115
+ result = {
116
+ "status": "ok",
117
+ "command": "generate",
118
+ "input": str(xml_path),
119
+ "output_dir": str(out_dir),
120
+ "files_generated": rel_files,
121
+ "warnings": [],
122
+ }
123
+ print(json.dumps(result, indent=2))
124
+ else:
125
+ print(f"OK: {len(saved)} files generated in {out_dir}")
126
+ for f in rel_files:
127
+ print(f" {f}")
128
+ return 0
129
+
130
+
131
+ def cmd_validate(args) -> int:
132
+ xml_path = args.xml
133
+
134
+ try:
135
+ tabs, gd, _rf, _cond, _lit, _settings = _load_project(xml_path)
136
+ except FileNotFoundError:
137
+ _err(f"error: XML file not found: {xml_path}")
138
+ return 2
139
+ except Exception as e:
140
+ _err(f"error: failed to load project: {e!r}")
141
+ return 2
142
+
143
+ if not tabs:
144
+ _err("error: no tabs/state machines found in project")
145
+ return 2
146
+
147
+ try:
148
+ from codegen.validate.validator import CodeGenerationValidator
149
+ validator = CodeGenerationValidator()
150
+ except Exception as e:
151
+ _err(f"error: validator init failed: {e!r}")
152
+ return 2
153
+
154
+ all_issues = []
155
+ total_errors = 0
156
+ total_warnings = 0
157
+
158
+ for name, sm in tabs:
159
+ try:
160
+ res = validator.validate(sm, gd)
161
+ except Exception as e:
162
+ _err(f"error: validation failed for tab {name!r}: {e!r}")
163
+ return 2
164
+ for issue in res.issues:
165
+ d = issue.to_dict()
166
+ d["tab"] = name
167
+ all_issues.append(d)
168
+ total_errors += res.error_count
169
+ total_warnings += res.warning_count
170
+
171
+ status = "ok" if total_errors == 0 else "error"
172
+
173
+ if args.format == "json":
174
+ out = {
175
+ "status": status,
176
+ "command": "validate",
177
+ "input": str(xml_path),
178
+ "errors": total_errors,
179
+ "warnings": total_warnings,
180
+ "issues": all_issues,
181
+ }
182
+ print(json.dumps(out, indent=2))
183
+ else:
184
+ print(
185
+ f"{status.upper()}: errors={total_errors} "
186
+ f"warnings={total_warnings}"
187
+ )
188
+ for d in all_issues:
189
+ sev = d.get("severity", "?")
190
+ code = d.get("code", "?")
191
+ msg = d.get("message", "")
192
+ print(f" [{sev}] {code}: {msg}")
193
+
194
+ if args.exit_on_error and total_errors > 0:
195
+ return 1
196
+ return 0
197
+
198
+
199
+ def cmd_version(args) -> int:
200
+ try:
201
+ from statable import __version__ as pkg_version
202
+ except Exception:
203
+ pkg_version = CLI_VERSION
204
+ print(f"statable-cli {pkg_version}")
205
+ return 0
206
+
207
+
208
+ def build_parser() -> argparse.ArgumentParser:
209
+ parser = argparse.ArgumentParser(
210
+ prog="statable-cli",
211
+ description="StaTable command-line interface",
212
+ )
213
+ parser.add_argument(
214
+ "--version",
215
+ action="version",
216
+ version=f"statable-cli {CLI_VERSION}",
217
+ )
218
+ sub = parser.add_subparsers(dest="command", required=True)
219
+
220
+ p_gen = sub.add_parser("generate", help="Generate C code from XML")
221
+ p_gen.add_argument("--xml", required=True,
222
+ help="Input design XML file")
223
+ p_gen.add_argument("--out", required=True,
224
+ help="Output directory")
225
+ p_gen.add_argument("--format", choices=["json", "text"],
226
+ default="json")
227
+ p_gen.set_defaults(func=cmd_generate)
228
+
229
+ p_val = sub.add_parser("validate", help="Validate XML design file")
230
+ p_val.add_argument("--xml", required=True,
231
+ help="Input design XML file")
232
+ p_val.add_argument("--format", choices=["json", "text"],
233
+ default="json")
234
+ p_val.add_argument("--exit-on-error", action="store_true",
235
+ help="Return exit code 1 on validation error")
236
+ p_val.set_defaults(func=cmd_validate)
237
+
238
+ p_ver = sub.add_parser("version", help="Print version")
239
+ p_ver.set_defaults(func=cmd_version)
240
+
241
+ return parser
242
+
243
+
244
+ def main(argv=None) -> int:
245
+ parser = build_parser()
246
+ args = parser.parse_args(argv)
247
+ return args.func(args)
248
+
249
+
250
+ if __name__ == "__main__":
251
+ sys.exit(main())
@@ -0,0 +1,256 @@
1
+ """Data model for global variables, event flags, interrupt handlers, device resources, timer settings, and user-defined types"""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import List, Optional
5
+
6
+
7
+ @dataclass
8
+ class StructMemberDef:
9
+ """Struct member definition"""
10
+ name: str
11
+ data_type: str
12
+ bit_width: int = 0
13
+ description: str = ""
14
+ title: str = ""
15
+ array_size: int = 0
16
+
17
+ def __post_init__(self):
18
+ if not self.title:
19
+ if self.bit_width > 0:
20
+ self.title = f"{self.name}:{self.bit_width}"
21
+ elif self.array_size > 0:
22
+ self.title = f"{self.name}[{self.array_size}]"
23
+ else:
24
+ self.title = f"Member: {self.name}"
25
+
26
+
27
+ @dataclass
28
+ class CustomTypeDef:
29
+ """User-defined types (structs, etc.)"""
30
+ name: str
31
+ description: str = ""
32
+ members: List[StructMemberDef] = field(default_factory=list)
33
+ title: str = ""
34
+
35
+ def __post_init__(self):
36
+ if not self.title:
37
+ self.title = f"Type: {self.name}"
38
+
39
+
40
+ @dataclass
41
+ class SystemVariable:
42
+ """Global variables shared across the system"""
43
+ name: str
44
+ type: str
45
+ unit: str = ""
46
+ default_value: str = ""
47
+ group: str = ""
48
+ description: str = ""
49
+ title: str = ""
50
+ array_size: int = 0
51
+
52
+ def __post_init__(self):
53
+ if not self.title:
54
+ if self.array_size > 0:
55
+ self.title = f"{self.name}[{self.array_size}]"
56
+ else:
57
+ self.title = f"Variable: {self.name}"
58
+
59
+
60
+ @dataclass
61
+ class EventFlag:
62
+ """Event flags managed as bit fields"""
63
+ name: str
64
+ min_value: int
65
+ max_value: int
66
+ group: str = ""
67
+ description: str = ""
68
+ title: str = ""
69
+
70
+ def __post_init__(self):
71
+ if not self.title:
72
+ self.title = f"Flag: {self.name}"
73
+
74
+ @property
75
+ def bit_width(self) -> int:
76
+ if self.max_value < self.min_value:
77
+ return 0
78
+ return (self.max_value - self.min_value).bit_length()
79
+
80
+
81
+ @dataclass
82
+ class InterruptAction:
83
+ """Conditional actions inside an interrupt handler"""
84
+ condition: str = ""
85
+ action: str = ""
86
+
87
+
88
+ @dataclass
89
+ class InterruptHandlerDef:
90
+ """Interrupt handler definition
91
+
92
+ used_role_functions: list of used role functions' qualified_name (auto-extracted)
93
+ - e.g. ["Driver.Init", "Application.HandleTick"]
94
+ - Updated when the ISR action is saved
95
+ - Also used in generated code comments
96
+
97
+ used_variables: list of used global variable names (auto-extracted)
98
+ - e.g. ["counter", "g_system_tick"]
99
+ """
100
+ name: str
101
+ description: str = ""
102
+ event_names: List[str] = field(default_factory=list)
103
+ is_timer: bool = False
104
+ actions: List[InterruptAction] = field(default_factory=list)
105
+ title: str = ""
106
+ # Added: usage record
107
+ used_role_functions: List[str] = field(default_factory=list)
108
+ used_variables: List[str] = field(default_factory=list)
109
+
110
+ def __post_init__(self):
111
+ if not self.title:
112
+ self.title = f"Interrupt: {self.name}"
113
+
114
+
115
+ @dataclass
116
+ class DevicePlaceholderDef:
117
+ """Device resource placeholder definition"""
118
+ name: str
119
+ description: str = ""
120
+ title: str = ""
121
+
122
+ def __post_init__(self):
123
+ if not self.title:
124
+ self.title = f"Device: {self.name}"
125
+
126
+
127
+ @dataclass
128
+ class TimerDerivedDef:
129
+ """Derived timer variable definition"""
130
+ period_name: str
131
+ multiplier: int
132
+ variable_name: str
133
+ data_type: str = "uint8_t"
134
+ title: str = ""
135
+
136
+ def __post_init__(self):
137
+ if not self.title:
138
+ self.title = f"Timer: {self.variable_name}"
139
+
140
+
141
+ @dataclass
142
+ class TimerBaseDef:
143
+ """Timer tick variable definition"""
144
+ variable_name: str = "g_system_tick"
145
+ unit: str = "1ms"
146
+ data_type: str = "volatile uint32_t"
147
+ derived: List[TimerDerivedDef] = field(default_factory=list)
148
+ title: str = ""
149
+ interrupt_name: str = ""
150
+
151
+ def __post_init__(self):
152
+ if not self.title:
153
+ self.title = f"Timer base: {self.variable_name}"
154
+
155
+
156
+ @dataclass
157
+ class EventQueueDef:
158
+ """Event queue definition"""
159
+ name: str
160
+ size: int
161
+ element_type: str
162
+ event_ids: List[str] = field(default_factory=list)
163
+ priority_enabled: bool = False
164
+ interrupt_safe: bool = True
165
+ rtos_enabled: bool = False
166
+ description: str = ""
167
+ title: str = ""
168
+
169
+ def __post_init__(self):
170
+ if not self.title:
171
+ self.title = f"Queue: {self.name}"
172
+
173
+
174
+ class GlobalDefinitions:
175
+ """Management class for global variables, event flags, interrupt handlers, device resources, timer settings, event queues, and user-defined types"""
176
+
177
+ def __init__(self):
178
+ self.variables: List[SystemVariable] = []
179
+ self.flags: List[EventFlag] = []
180
+ self.interrupts: List[InterruptHandlerDef] = []
181
+ self.placeholders: List[DevicePlaceholderDef] = []
182
+ self.timer_base: TimerBaseDef = TimerBaseDef()
183
+ self.extra_timers: List[TimerBaseDef] = []
184
+ self.event_queues: List[EventQueueDef] = []
185
+ self.custom_types: List[CustomTypeDef] = []
186
+
187
+ def add_timer_variables(self):
188
+ """Timer base variables and derived timer variables are registered as global variables.
189
+
190
+ - Existing variables (loaded from XML or edited in GUI) preserve description / title
191
+ - Only type / unit are synchronized with the timer definition
192
+ - Only non-existing variables are newly added
193
+
194
+ This ensures description / title are preserved across round-trips.
195
+ """
196
+ existing_by_name = {v.name: v for v in self.variables}
197
+
198
+ def _sync_or_add(name, type_, unit, default, group,
199
+ default_desc, default_title):
200
+ if name in existing_by_name:
201
+ v = existing_by_name[name]
202
+ # Treat timer definition as authoritative (type, unit)
203
+ v.type = type_
204
+ v.unit = unit
205
+ # Preserve existing description / title
206
+ if not v.description:
207
+ v.description = default_desc
208
+ if not v.title:
209
+ v.title = default_title
210
+ # If group is unset, fill in
211
+ if not v.group:
212
+ v.group = group
213
+ else:
214
+ self.variables.append(SystemVariable(
215
+ name=name,
216
+ type=type_,
217
+ unit=unit,
218
+ default_value=default,
219
+ group=group,
220
+ description=default_desc,
221
+ title=default_title,
222
+ ))
223
+
224
+ all_timers = [self.timer_base] + self.extra_timers
225
+ for timer in all_timers:
226
+ # Base timer variable
227
+ _sync_or_add(
228
+ name=timer.variable_name,
229
+ type_=timer.data_type,
230
+ unit=timer.unit,
231
+ default="0",
232
+ group="Timer",
233
+ default_desc="Timer base variable",
234
+ default_title=timer.title or f"Timer base: {timer.variable_name}",
235
+ )
236
+ # Derived timer variable
237
+ for d in timer.derived:
238
+ _sync_or_add(
239
+ name=d.variable_name,
240
+ type_=d.data_type,
241
+ unit=d.period_name,
242
+ default="0",
243
+ group="Timer",
244
+ default_desc=f"Derived timer variable ({d.period_name})",
245
+ default_title=d.title or f"Timer: {d.variable_name}",
246
+ )
247
+
248
+ # Get group name
249
+ def variable_groups(self) -> List[str]:
250
+ return sorted({v.group for v in self.variables if v.group})
251
+
252
+ def flag_groups(self) -> List[str]:
253
+ return sorted({f.group for f in self.flags if f.group})
254
+
255
+ def custom_type_names(self) -> List[str]:
256
+ return sorted({t.name for t in self.custom_types})
@@ -0,0 +1,144 @@
1
+ from .state_machine import StateMachine
2
+
3
+
4
+ # ============================================================
5
+ # Mermaid label sanitization (v2.0 / v2.2)
6
+ # All replacements use ASCII to satisfy the English-only policy.
7
+ # ============================================================
8
+ _MERMAID_LABEL_REPLACEMENTS = (
9
+ (":", "-"), # colon -> dash
10
+ ("[", "("), # opening bracket -> paren
11
+ ("]", ")"), # closing bracket -> paren
12
+ ('"', "'"),
13
+ ("`", "'"),
14
+ ("\n", " "),
15
+ ("\r", " "),
16
+ )
17
+
18
+
19
+ def _sanitize_label(text: str) -> str:
20
+ """Sanitize the Mermaid label string."""
21
+ if not text:
22
+ return ""
23
+ for src, dst in _MERMAID_LABEL_REPLACEMENTS:
24
+ text = text.replace(src, dst)
25
+ text = " ".join(text.split())
26
+ return text
27
+
28
+
29
+ def _truncate_condition(condition: str, max_chars: int = 50) -> str:
30
+ """Show long state transition conditions abbreviated."""
31
+ if not condition:
32
+ return ""
33
+ lines = condition.split('\n')
34
+ first_line = lines[0].strip() if lines else ""
35
+ if not first_line:
36
+ return ""
37
+ if len(first_line) > max_chars:
38
+ return first_line[:max_chars].rstrip() + "..."
39
+ if len(lines) > 1:
40
+ return first_line + " ..."
41
+ return first_line
42
+
43
+
44
+ def _mode_suffix(trans) -> str:
45
+ """
46
+ v2.2: Return a short mode suffix for the transition label.
47
+
48
+ "C" -> Commit (early_return=True)
49
+ "T" -> Tentative (early_return=False)
50
+ Empty string is returned when early_return is not set (backward compat).
51
+ """
52
+ er = getattr(trans, 'early_return', None)
53
+ if er is True:
54
+ return " [Commit]"
55
+ return ""
56
+
57
+
58
+ def _build_label(trans, *, short_mode: bool = True) -> str:
59
+ """Build the Mermaid label for a single transition."""
60
+ label_parts = []
61
+
62
+ # ---- Title ----
63
+ title_raw = getattr(trans, 'title', '') or ""
64
+ title_safe = _sanitize_label(title_raw)
65
+ if title_safe and title_safe != "(untitled transition)":
66
+ label_parts.append(title_safe)
67
+ else:
68
+ if getattr(trans, 'target', ''):
69
+ label_parts.append(_sanitize_label(trans.target))
70
+ else:
71
+ label_parts.append("(internal)")
72
+
73
+ # ---- Event ----
74
+ ev = getattr(trans, 'event', '') or ""
75
+ if ev:
76
+ ev_safe = _sanitize_label(ev)
77
+ if ev_safe:
78
+ label_parts.append(f"({ev_safe})")
79
+
80
+ # ---- Condition ----
81
+ cond = getattr(trans, 'condition', '') or ""
82
+ if cond:
83
+ cond_text = _sanitize_label(_truncate_condition(cond))
84
+ if cond_text:
85
+ label_parts.append(f"[{cond_text}]")
86
+
87
+ # ---- Mode (v2.2) ----
88
+ mode = _mode_suffix(trans)
89
+ if mode:
90
+ label_parts.append(mode)
91
+
92
+ return " ".join(label_parts).strip()
93
+
94
+
95
+ def generate_mermaid(sm: StateMachine) -> str:
96
+ """
97
+ Generate a Mermaid stateDiagram-v2 string from a StateMachine.
98
+
99
+ [v2.2 changes]
100
+ - Each transition in a cell becomes its own edge.
101
+ - else_target is emitted as a separate edge (dashed style not
102
+ available in stateDiagram-v2, so we use label "else").
103
+ - early_return=True adds " [Commit]" to the label.
104
+ - entry / exit are NOT rendered (metadata only).
105
+ """
106
+ lines = ["stateDiagram-v2", " direction LR"]
107
+
108
+ if sm.initial_state:
109
+ lines.append(f" [*] --> {sm.initial_state}")
110
+
111
+ for t in sm.transitions:
112
+ # ---- Primary edge (target) ----
113
+ if t.target:
114
+ label = _build_label(t)
115
+ lines.append(f" {t.source} --> {t.target} : {label}")
116
+ else:
117
+ # No target: internal action only -> attach a note
118
+ label = _build_label(t)
119
+ lines.append(f" note right of {t.source} : internal: {label}")
120
+
121
+ # ---- Secondary edge (else_target) ----
122
+ else_target = getattr(t, 'else_target', '') or ''
123
+ has_else = getattr(t, 'has_else', True)
124
+ if has_else and else_target:
125
+ else_label_parts = []
126
+ # Show event name (if any) so the edge is identifiable
127
+ ev = getattr(t, 'event', '') or ""
128
+ if ev:
129
+ ev_safe = _sanitize_label(ev)
130
+ if ev_safe:
131
+ else_label_parts.append(f"({ev_safe})")
132
+ else:
133
+ else_label_parts.append("(completion)")
134
+ else_label_parts.append("else")
135
+ # Add mode suffix
136
+ mode = _mode_suffix(t)
137
+ if mode:
138
+ else_label_parts.append(mode)
139
+ else_label = " ".join(else_label_parts)
140
+ lines.append(
141
+ f" {t.source} --> {else_target} : {else_label}"
142
+ )
143
+
144
+ return "\n".join(lines)