|
2 | 2 | import logging |
3 | 3 | from textwrap import fill |
4 | 4 | from typing import List |
| 5 | +import os |
| 6 | +import sys |
5 | 7 |
|
6 | 8 | # Non-breaking space |
7 | 9 | NBSP = '\xa0' |
8 | 10 |
|
9 | 11 | # HTML Word Break Opportunity |
10 | 12 | WBR = '<wbr>' |
11 | 13 |
|
| 14 | +# Import railroad diagrams library |
| 15 | +try: |
| 16 | + import railroad |
| 17 | +except ImportError: |
| 18 | + railroad = None |
| 19 | + logging.warning("railroad-diagrams library not available. Railroad diagram generation will be skipped.") |
| 20 | + |
12 | 21 | class ArgumentType(Enum): |
13 | 22 | INTEGER = 'integer' |
14 | 23 | DOUBLE = 'double' |
@@ -93,6 +102,131 @@ def syntax(self, **kwargs) -> str: |
93 | 102 | logging.debug("EXITING: ") |
94 | 103 | return f'{syntax}' |
95 | 104 |
|
| 105 | + def to_railroad(self) -> 'railroad.Node': |
| 106 | + """Convert this argument to a railroad diagram component.""" |
| 107 | + if railroad is None: |
| 108 | + raise ImportError("railroad-diagrams library not available") |
| 109 | + |
| 110 | + logging.debug(f"Converting argument '{self._name}' of type {self._type} to railroad") |
| 111 | + |
| 112 | + # Handle different argument types |
| 113 | + if self._type == ArgumentType.PURE_TOKEN: |
| 114 | + # Pure tokens are just terminal text |
| 115 | + component = railroad.Terminal(self._token or self._name) |
| 116 | + elif self._type == ArgumentType.BLOCK: |
| 117 | + # Blocks are sequences of their arguments |
| 118 | + components = [] |
| 119 | + |
| 120 | + # If the block has a token, add it first |
| 121 | + if self._token: |
| 122 | + components.append(railroad.Terminal(self._token)) |
| 123 | + |
| 124 | + # Add the block's arguments |
| 125 | + if self._arguments: |
| 126 | + components.extend([arg.to_railroad() for arg in self._arguments]) |
| 127 | + |
| 128 | + if components: |
| 129 | + component = railroad.Sequence(*components) |
| 130 | + else: |
| 131 | + component = railroad.Terminal(self._display) |
| 132 | + elif self._type == ArgumentType.ONEOF: |
| 133 | + # OneOf is a choice between arguments |
| 134 | + if self._arguments: |
| 135 | + components = [arg.to_railroad() for arg in self._arguments] |
| 136 | + # Use the first option as the default (index 0) |
| 137 | + choice_component = railroad.Choice(0, *components) |
| 138 | + |
| 139 | + # If there's a token, create a sequence of token + choice |
| 140 | + if self._token: |
| 141 | + token_part = railroad.Terminal(self._token) |
| 142 | + component = railroad.Sequence(token_part, choice_component) |
| 143 | + else: |
| 144 | + component = choice_component |
| 145 | + else: |
| 146 | + component = railroad.Terminal(self._display) |
| 147 | + else: |
| 148 | + # Regular arguments (string, integer, etc.) |
| 149 | + if self._token: |
| 150 | + # If there's a token, create a sequence of token + argument |
| 151 | + token_part = railroad.Terminal(self._token) |
| 152 | + arg_part = railroad.NonTerminal(self._display) |
| 153 | + component = railroad.Sequence(token_part, arg_part) |
| 154 | + else: |
| 155 | + # Just the argument |
| 156 | + component = railroad.NonTerminal(self._display) |
| 157 | + |
| 158 | + # Handle multiple (repeating) arguments |
| 159 | + if self._multiple: |
| 160 | + if self._type == ArgumentType.ONEOF: |
| 161 | + # For ONEOF with multiple=true, we want to allow selecting multiple options |
| 162 | + # This means: first_option [additional_options ...] |
| 163 | + # where additional_options is a choice of any of the original options |
| 164 | + if self._arguments: |
| 165 | + # Create a choice of all options for the additional selections |
| 166 | + additional_choice = railroad.Choice(0, *[arg.to_railroad() for arg in self._arguments]) |
| 167 | + |
| 168 | + # If there's a token, we need to include it in the repetition |
| 169 | + if self._token: |
| 170 | + token_part = railroad.Terminal(self._token) |
| 171 | + repeat_part = railroad.Sequence(token_part, additional_choice) |
| 172 | + component = railroad.Sequence(component, railroad.ZeroOrMore(repeat_part)) |
| 173 | + else: |
| 174 | + component = railroad.Sequence(component, railroad.ZeroOrMore(additional_choice)) |
| 175 | + else: |
| 176 | + component = railroad.OneOrMore(component) |
| 177 | + elif self._multiple_token and self._token: |
| 178 | + # For types with multiple_token=true, the token should be repeated with each occurrence |
| 179 | + if self._type == ArgumentType.BLOCK and self._arguments: |
| 180 | + # For BLOCK types: extract the arguments part for repetition to avoid duplicate tokens |
| 181 | + args_component = railroad.Sequence(*[arg.to_railroad() for arg in self._arguments]) |
| 182 | + repeat_part = railroad.Sequence(railroad.Terminal(self._token), args_component) |
| 183 | + component = railroad.Sequence(component, railroad.ZeroOrMore(repeat_part)) |
| 184 | + else: |
| 185 | + # For non-BLOCK types: create the complete pattern from scratch |
| 186 | + # Pattern: TOKEN arg [TOKEN arg ...] |
| 187 | + if self._type == ArgumentType.INTEGER: |
| 188 | + arg_part = railroad.NonTerminal(self._display) |
| 189 | + elif self._type == ArgumentType.STRING: |
| 190 | + arg_part = railroad.NonTerminal(self._display) |
| 191 | + elif self._type == ArgumentType.KEY: |
| 192 | + arg_part = railroad.NonTerminal(self._display) |
| 193 | + else: |
| 194 | + arg_part = railroad.NonTerminal(self._display) |
| 195 | + |
| 196 | + # Create the first occurrence: TOKEN arg |
| 197 | + first_occurrence = railroad.Sequence(railroad.Terminal(self._token), arg_part) |
| 198 | + # Create the repeat pattern: [TOKEN arg ...] |
| 199 | + repeat_part = railroad.Sequence(railroad.Terminal(self._token), arg_part) |
| 200 | + # Combine: TOKEN arg [TOKEN arg ...] |
| 201 | + component = railroad.Sequence(first_occurrence, railroad.ZeroOrMore(repeat_part)) |
| 202 | + elif self._token and self._multiple: |
| 203 | + # For non-BLOCK types with multiple=true and a token: |
| 204 | + # The token appears once, followed by one or more arguments |
| 205 | + # Pattern: TOKEN arg [arg ...] |
| 206 | + if self._type != ArgumentType.BLOCK: |
| 207 | + # Create the argument part without the token for repetition |
| 208 | + if self._type == ArgumentType.INTEGER: |
| 209 | + arg_part = railroad.NonTerminal(self._display) |
| 210 | + elif self._type == ArgumentType.STRING: |
| 211 | + arg_part = railroad.NonTerminal(self._display) |
| 212 | + elif self._type == ArgumentType.KEY: |
| 213 | + arg_part = railroad.NonTerminal(self._display) |
| 214 | + else: |
| 215 | + arg_part = railroad.NonTerminal(self._display) |
| 216 | + |
| 217 | + # Token + first arg + [additional args ...] |
| 218 | + token_part = railroad.Terminal(self._token) |
| 219 | + component = railroad.Sequence(token_part, railroad.OneOrMore(arg_part)) |
| 220 | + else: |
| 221 | + # Multiple without token: arg [arg ...] |
| 222 | + component = railroad.OneOrMore(component) |
| 223 | + |
| 224 | + # Handle optional arguments |
| 225 | + if self._optional: |
| 226 | + component = railroad.Optional(component) |
| 227 | + |
| 228 | + return component |
| 229 | + |
96 | 230 |
|
97 | 231 | class Command(Argument): |
98 | 232 | def __init__(self, cname: str, data: dict, max_width: int = 640) -> None: |
@@ -125,3 +259,138 @@ def syntax(self, **kwargs): |
125 | 259 | result = fill(' '.join(args), **opts) |
126 | 260 | logging.debug("EXITING: ") |
127 | 261 | return result |
| 262 | + |
| 263 | + def to_railroad_diagram(self, output_path: str = None) -> str: |
| 264 | + """Generate a railroad diagram for this command and return the SVG content.""" |
| 265 | + if railroad is None: |
| 266 | + raise ImportError("railroad-diagrams library not available") |
| 267 | + |
| 268 | + logging.debug(f"Generating railroad diagram for command: {self._cname}") |
| 269 | + |
| 270 | + # Create the main command terminal |
| 271 | + command_terminal = railroad.Terminal(self._cname) |
| 272 | + |
| 273 | + # Convert all arguments to railroad components |
| 274 | + arg_components = [] |
| 275 | + for arg in self._arguments: |
| 276 | + try: |
| 277 | + arg_components.append(arg.to_railroad()) |
| 278 | + except Exception as e: |
| 279 | + logging.warning(f"Failed to convert argument {arg._name} to railroad: {e}") |
| 280 | + # Fallback to a simple terminal |
| 281 | + arg_components.append(railroad.NonTerminal(arg._name)) |
| 282 | + |
| 283 | + # Create the complete diagram |
| 284 | + if arg_components: |
| 285 | + diagram_content = railroad.Sequence(command_terminal, *arg_components) |
| 286 | + else: |
| 287 | + diagram_content = command_terminal |
| 288 | + |
| 289 | + # Create the diagram |
| 290 | + diagram = railroad.Diagram(diagram_content) |
| 291 | + |
| 292 | + # Generate SVG |
| 293 | + svg_content = [] |
| 294 | + diagram.writeSvg(svg_content.append) |
| 295 | + svg_string = ''.join(svg_content) |
| 296 | + |
| 297 | + # Apply Redis red styling and transparent background |
| 298 | + svg_string = self._apply_redis_styling(svg_string) |
| 299 | + |
| 300 | + # Save to file if output path is provided |
| 301 | + if output_path: |
| 302 | + os.makedirs(os.path.dirname(output_path), exist_ok=True) |
| 303 | + with open(output_path, 'w', encoding='utf-8') as f: |
| 304 | + f.write(svg_string) |
| 305 | + logging.info(f"Railroad diagram saved to: {output_path}") |
| 306 | + |
| 307 | + return svg_string |
| 308 | + |
| 309 | + def _apply_redis_styling(self, svg_content: str) -> str: |
| 310 | + """ |
| 311 | + Apply Redis red color scheme and transparent background to SVG. |
| 312 | +
|
| 313 | + Args: |
| 314 | + svg_content: Original SVG content |
| 315 | +
|
| 316 | + Returns: |
| 317 | + Modified SVG content with Redis styling |
| 318 | + """ |
| 319 | + # Redis red color: #DC382D |
| 320 | + redis_red = "#DC382D" |
| 321 | + |
| 322 | + # Make background transparent by removing fill from the main SVG |
| 323 | + svg_content = svg_content.replace('fill="white"', 'fill="none"') |
| 324 | + svg_content = svg_content.replace('fill="#fff"', 'fill="none"') |
| 325 | + |
| 326 | + # Add custom CSS styling for Redis theme |
| 327 | + style_css = f'''<defs> |
| 328 | +<style type="text/css"><![CDATA[ |
| 329 | +svg.railroad-diagram {{ background-color: transparent !important; }} |
| 330 | +.terminal rect {{ fill: {redis_red} !important; stroke: {redis_red} !important; }} |
| 331 | +.terminal text {{ fill: white !important; font-weight: bold; }} |
| 332 | +.nonterminal rect {{ fill: none !important; stroke: {redis_red} !important; stroke-width: 2; }} |
| 333 | +.nonterminal text {{ fill: {redis_red} !important; font-weight: bold; }} |
| 334 | +path {{ stroke: {redis_red} !important; stroke-width: 2; fill: none; }} |
| 335 | +circle {{ fill: {redis_red} !important; stroke: {redis_red} !important; }} |
| 336 | +]]></style> |
| 337 | +</defs>''' |
| 338 | + |
| 339 | + # Insert the style after the opening SVG tag |
| 340 | + import re |
| 341 | + if '<defs>' in svg_content: |
| 342 | + # Replace existing defs |
| 343 | + svg_content = re.sub(r'<defs>.*?</defs>', style_css, svg_content, flags=re.DOTALL) |
| 344 | + else: |
| 345 | + # Insert new defs after svg opening tag |
| 346 | + svg_content = re.sub(r'<svg([^>]*)>', f'<svg\\1>\n{style_css}', svg_content, count=1) |
| 347 | + |
| 348 | + # Override any existing background color and stroke styles |
| 349 | + import re |
| 350 | + |
| 351 | + # Replace the entire default style section with our Redis-themed styles |
| 352 | + default_style_pattern = r'<style>/\* <!\[CDATA\[ \*/.*?/\* \]\]> \*/</style>' |
| 353 | + redis_style_replacement = f'''<style>/* <![CDATA[ */ |
| 354 | + svg.railroad-diagram {{ |
| 355 | + background-color: transparent; |
| 356 | + }} |
| 357 | + svg.railroad-diagram path {{ |
| 358 | + stroke-width: 2; |
| 359 | + stroke: {redis_red}; |
| 360 | + fill: rgba(0,0,0,0); |
| 361 | + }} |
| 362 | + svg.railroad-diagram text {{ |
| 363 | + font: bold 14px monospace; |
| 364 | + text-anchor: middle; |
| 365 | + fill: {redis_red}; |
| 366 | + }} |
| 367 | + svg.railroad-diagram text.label {{ |
| 368 | + text-anchor: start; |
| 369 | + }} |
| 370 | + svg.railroad-diagram text.comment {{ |
| 371 | + font: italic 12px monospace; |
| 372 | + }} |
| 373 | + svg.railroad-diagram rect {{ |
| 374 | + stroke-width: 2; |
| 375 | + stroke: {redis_red}; |
| 376 | + fill: none; |
| 377 | + }} |
| 378 | + svg.railroad-diagram rect.group-box {{ |
| 379 | + stroke: {redis_red}; |
| 380 | + stroke-dasharray: 10 5; |
| 381 | + fill: none; |
| 382 | + }} |
| 383 | + /* ]]> */</style>''' |
| 384 | + |
| 385 | + svg_content = re.sub(default_style_pattern, redis_style_replacement, svg_content, flags=re.DOTALL) |
| 386 | + |
| 387 | + # Additional specific overrides for any remaining default colors |
| 388 | + svg_content = re.sub(r'fill:hsl\(120,100%,90%\)', 'fill: none', svg_content) |
| 389 | + svg_content = re.sub(r'stroke: gray', f'stroke: {redis_red}', svg_content) |
| 390 | + |
| 391 | + # Additional fallback overrides |
| 392 | + svg_content = re.sub(r'background-color:\s*[^;]+;', 'background-color: transparent;', svg_content) |
| 393 | + svg_content = re.sub(r'stroke:\s*black;', f'stroke: {redis_red};', svg_content) |
| 394 | + svg_content = re.sub(r'stroke:\s*#000;', f'stroke: {redis_red};', svg_content) |
| 395 | + |
| 396 | + return svg_content |
0 commit comments