yuho 5.0.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 (91) hide show
  1. yuho/__init__.py +16 -0
  2. yuho/ast/__init__.py +196 -0
  3. yuho/ast/builder.py +926 -0
  4. yuho/ast/constant_folder.py +280 -0
  5. yuho/ast/dead_code.py +199 -0
  6. yuho/ast/exhaustiveness.py +503 -0
  7. yuho/ast/nodes.py +907 -0
  8. yuho/ast/overlap.py +291 -0
  9. yuho/ast/reachability.py +293 -0
  10. yuho/ast/scope_analysis.py +490 -0
  11. yuho/ast/transformer.py +490 -0
  12. yuho/ast/type_check.py +471 -0
  13. yuho/ast/type_inference.py +425 -0
  14. yuho/ast/visitor.py +239 -0
  15. yuho/cli/__init__.py +14 -0
  16. yuho/cli/commands/__init__.py +1 -0
  17. yuho/cli/commands/api.py +431 -0
  18. yuho/cli/commands/ast_viz.py +334 -0
  19. yuho/cli/commands/check.py +218 -0
  20. yuho/cli/commands/config.py +311 -0
  21. yuho/cli/commands/contribute.py +122 -0
  22. yuho/cli/commands/diff.py +487 -0
  23. yuho/cli/commands/explain.py +240 -0
  24. yuho/cli/commands/fmt.py +253 -0
  25. yuho/cli/commands/generate.py +316 -0
  26. yuho/cli/commands/graph.py +410 -0
  27. yuho/cli/commands/init.py +120 -0
  28. yuho/cli/commands/library.py +656 -0
  29. yuho/cli/commands/lint.py +503 -0
  30. yuho/cli/commands/lsp.py +36 -0
  31. yuho/cli/commands/preview.py +377 -0
  32. yuho/cli/commands/repl.py +444 -0
  33. yuho/cli/commands/serve.py +44 -0
  34. yuho/cli/commands/test.py +528 -0
  35. yuho/cli/commands/transpile.py +121 -0
  36. yuho/cli/commands/wizard.py +370 -0
  37. yuho/cli/completions.py +182 -0
  38. yuho/cli/error_formatter.py +193 -0
  39. yuho/cli/main.py +1064 -0
  40. yuho/config/__init__.py +46 -0
  41. yuho/config/loader.py +235 -0
  42. yuho/config/mask.py +194 -0
  43. yuho/config/schema.py +147 -0
  44. yuho/library/__init__.py +84 -0
  45. yuho/library/index.py +328 -0
  46. yuho/library/install.py +699 -0
  47. yuho/library/lockfile.py +330 -0
  48. yuho/library/package.py +421 -0
  49. yuho/library/resolver.py +791 -0
  50. yuho/library/signature.py +335 -0
  51. yuho/llm/__init__.py +45 -0
  52. yuho/llm/config.py +75 -0
  53. yuho/llm/factory.py +123 -0
  54. yuho/llm/prompts.py +146 -0
  55. yuho/llm/providers.py +383 -0
  56. yuho/llm/utils.py +470 -0
  57. yuho/lsp/__init__.py +14 -0
  58. yuho/lsp/code_action_handler.py +518 -0
  59. yuho/lsp/completion_handler.py +85 -0
  60. yuho/lsp/diagnostics.py +100 -0
  61. yuho/lsp/hover_handler.py +130 -0
  62. yuho/lsp/server.py +1425 -0
  63. yuho/mcp/__init__.py +10 -0
  64. yuho/mcp/server.py +1452 -0
  65. yuho/parser/__init__.py +8 -0
  66. yuho/parser/source_location.py +108 -0
  67. yuho/parser/wrapper.py +311 -0
  68. yuho/testing/__init__.py +48 -0
  69. yuho/testing/coverage.py +274 -0
  70. yuho/testing/fixtures.py +263 -0
  71. yuho/transpile/__init__.py +52 -0
  72. yuho/transpile/alloy_transpiler.py +546 -0
  73. yuho/transpile/base.py +100 -0
  74. yuho/transpile/blocks_transpiler.py +338 -0
  75. yuho/transpile/english_transpiler.py +470 -0
  76. yuho/transpile/graphql_transpiler.py +404 -0
  77. yuho/transpile/json_transpiler.py +217 -0
  78. yuho/transpile/jsonld_transpiler.py +250 -0
  79. yuho/transpile/latex_preamble.py +161 -0
  80. yuho/transpile/latex_transpiler.py +406 -0
  81. yuho/transpile/latex_utils.py +206 -0
  82. yuho/transpile/mermaid_transpiler.py +357 -0
  83. yuho/transpile/registry.py +275 -0
  84. yuho/verify/__init__.py +43 -0
  85. yuho/verify/alloy.py +352 -0
  86. yuho/verify/combined.py +218 -0
  87. yuho/verify/z3_solver.py +1155 -0
  88. yuho-5.0.0.dist-info/METADATA +186 -0
  89. yuho-5.0.0.dist-info/RECORD +91 -0
  90. yuho-5.0.0.dist-info/WHEEL +4 -0
  91. yuho-5.0.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,528 @@
1
+ """
2
+ Test command - run tests for Yuho statute files.
3
+ """
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional, List
9
+ from datetime import datetime
10
+
11
+ import click
12
+
13
+ from yuho.parser import Parser
14
+ from yuho.ast import ASTBuilder
15
+ from yuho.cli.error_formatter import Colors, colorize
16
+
17
+
18
+ def run_test(
19
+ file: Optional[str] = None,
20
+ run_all: bool = False,
21
+ json_output: bool = False,
22
+ verbose: bool = False,
23
+ coverage: bool = False,
24
+ coverage_html: Optional[str] = None,
25
+ ) -> None:
26
+ """
27
+ Run tests for Yuho statute files.
28
+
29
+ Args:
30
+ file: Path to .yh file (looks for associated test file)
31
+ run_all: Run all tests in current directory
32
+ json_output: Output results as JSON
33
+ verbose: Enable verbose output
34
+ coverage: Enable coverage tracking
35
+ coverage_html: Path to write HTML coverage report
36
+ """
37
+ test_files: List[Path] = []
38
+ statute_files: List[Path] = []
39
+
40
+ if run_all:
41
+ # Find all test files
42
+ cwd = Path.cwd()
43
+ test_files.extend(cwd.glob("test_*.yh"))
44
+ test_files.extend(cwd.glob("tests/*_test.yh"))
45
+ test_files.extend(cwd.glob("**/test_*.yh"))
46
+ # Find statute files for coverage
47
+ if coverage:
48
+ statute_files.extend(f for f in cwd.glob("**/*.yh") if "test" not in f.name.lower())
49
+ elif file:
50
+ file_path = Path(file)
51
+ if coverage:
52
+ statute_files.append(file_path)
53
+ # Look for associated test file
54
+ candidates = [
55
+ file_path.parent / f"test_{file_path.name}",
56
+ file_path.parent / "tests" / f"{file_path.stem}_test.yh",
57
+ file_path.parent / "tests" / f"test_{file_path.name}",
58
+ ]
59
+ for candidate in candidates:
60
+ if candidate.exists():
61
+ test_files.append(candidate)
62
+ break
63
+
64
+ if not test_files:
65
+ click.echo(colorize(f"No test file found for {file}", Colors.YELLOW))
66
+ click.echo("Expected:")
67
+ for c in candidates:
68
+ click.echo(f" - {c}")
69
+ sys.exit(1)
70
+ else:
71
+ click.echo(colorize("error: Specify a file or use --all", Colors.RED), err=True)
72
+ sys.exit(1)
73
+
74
+ if not test_files:
75
+ click.echo(colorize("No test files found", Colors.YELLOW))
76
+ sys.exit(0)
77
+
78
+ # Initialize coverage tracker if needed
79
+ coverage_tracker = None
80
+ if coverage:
81
+ from yuho.testing.coverage import CoverageTracker
82
+ coverage_tracker = CoverageTracker()
83
+
84
+ # Load statutes for coverage tracking
85
+ for statute_file in statute_files:
86
+ try:
87
+ parser = Parser()
88
+ result = parser.parse_file(statute_file)
89
+ if result.is_valid:
90
+ builder = ASTBuilder(result.source, str(statute_file))
91
+ ast = builder.build(result.root_node)
92
+ coverage_tracker.load_statutes_from_ast(ast)
93
+ except Exception:
94
+ continue
95
+
96
+ # Run tests
97
+ results = []
98
+ passed = 0
99
+ failed = 0
100
+
101
+ for test_file in test_files:
102
+ if verbose:
103
+ click.echo(f"Running {test_file}...")
104
+
105
+ result = _run_test_file(test_file, verbose, coverage_tracker)
106
+ results.append(result)
107
+
108
+ if result["passed"]:
109
+ passed += 1
110
+ if not json_output:
111
+ click.echo(colorize(f" PASS: {test_file.name}", Colors.CYAN))
112
+ else:
113
+ failed += 1
114
+ if not json_output:
115
+ click.echo(colorize(f" FAIL: {test_file.name}", Colors.RED))
116
+ for err in result.get("errors", []):
117
+ click.echo(f" - {err}")
118
+
119
+ # Generate coverage report
120
+ if coverage and coverage_tracker:
121
+ report = coverage_tracker.generate_report()
122
+
123
+ if coverage_html:
124
+ html_path = Path(coverage_html)
125
+ _generate_html_coverage_report(report, html_path)
126
+ if not json_output:
127
+ click.echo(f"\nCoverage report written to: {html_path}")
128
+ elif not json_output:
129
+ coverage_tracker.print_summary()
130
+
131
+ # Summary
132
+ if json_output:
133
+ output_data = {
134
+ "total": len(test_files),
135
+ "passed": passed,
136
+ "failed": failed,
137
+ "results": results,
138
+ }
139
+ if coverage and coverage_tracker:
140
+ output_data["coverage"] = coverage_tracker.generate_report().to_dict()
141
+ print(json.dumps(output_data, indent=2))
142
+ else:
143
+ click.echo()
144
+ if failed == 0:
145
+ click.echo(colorize(f"All {passed} tests passed", Colors.CYAN + Colors.BOLD))
146
+ else:
147
+ click.echo(colorize(f"{passed} passed, {failed} failed", Colors.RED + Colors.BOLD))
148
+ sys.exit(1)
149
+
150
+
151
+ def _run_test_file(test_file: Path, verbose: bool, coverage_tracker=None) -> dict:
152
+ """Run a single test file and return results."""
153
+ result = {
154
+ "file": str(test_file),
155
+ "passed": False,
156
+ "errors": [],
157
+ "assertions": {"passed": 0, "failed": 0},
158
+ }
159
+
160
+ # Parse test file
161
+ parser = Parser()
162
+ try:
163
+ parse_result = parser.parse_file(test_file)
164
+ except Exception as e:
165
+ result["errors"].append(f"Parse error: {e}")
166
+ if coverage_tracker:
167
+ coverage_tracker.add_test_result(passed=False)
168
+ return result
169
+
170
+ if parse_result.errors:
171
+ result["errors"].extend(f"{e.location}: {e.message}" for e in parse_result.errors)
172
+ if coverage_tracker:
173
+ coverage_tracker.add_test_result(passed=False)
174
+ return result
175
+
176
+ # Build AST
177
+ try:
178
+ builder = ASTBuilder(parse_result.source, str(test_file))
179
+ ast = builder.build(parse_result.root_node)
180
+ except Exception as e:
181
+ result["errors"].append(f"AST error: {e}")
182
+ if coverage_tracker:
183
+ coverage_tracker.add_test_result(passed=False)
184
+ return result
185
+
186
+ # Evaluate assertions if present
187
+ if hasattr(ast, 'assertions') and ast.assertions:
188
+ env = _build_test_environment(ast)
189
+ for assertion in ast.assertions:
190
+ try:
191
+ passed, error_msg = _evaluate_assertion(assertion, env, verbose)
192
+ if passed:
193
+ result["assertions"]["passed"] += 1
194
+ else:
195
+ result["assertions"]["failed"] += 1
196
+ loc = assertion.source_location
197
+ loc_str = f"{loc.line}:{loc.col}" if loc else "?"
198
+ result["errors"].append(f"Assertion failed at {loc_str}: {error_msg}")
199
+ except Exception as e:
200
+ result["assertions"]["failed"] += 1
201
+ result["errors"].append(f"Assertion evaluation error: {e}")
202
+
203
+ # Track coverage if enabled
204
+ if coverage_tracker:
205
+ for statute in ast.statutes:
206
+ section = statute.section_number
207
+
208
+ # Mark elements as covered
209
+ for elem in (statute.elements or []):
210
+ coverage_tracker.mark_element_covered(
211
+ section,
212
+ elem.element_type,
213
+ elem.name,
214
+ str(test_file),
215
+ )
216
+
217
+ # Mark penalty as covered if present
218
+ if statute.penalty:
219
+ coverage_tracker.mark_penalty_covered(section)
220
+
221
+ # Mark illustrations as covered
222
+ if hasattr(statute, 'illustrations') and statute.illustrations:
223
+ for ill in statute.illustrations:
224
+ if hasattr(ill, 'label') and ill.label:
225
+ coverage_tracker.mark_illustration_covered(section, ill.label)
226
+
227
+ coverage_tracker.add_test_result(passed=result["assertions"]["failed"] == 0)
228
+
229
+ # Test passes if no assertion failures and no parse errors
230
+ result["passed"] = result["assertions"]["failed"] == 0 and len(result["errors"]) == 0
231
+ result["stats"] = {
232
+ "statutes": len(ast.statutes),
233
+ "functions": len(ast.function_defs),
234
+ "assertions_total": result["assertions"]["passed"] + result["assertions"]["failed"],
235
+ }
236
+
237
+ return result
238
+
239
+
240
+ def _build_test_environment(ast) -> dict:
241
+ """Build an environment mapping variable names to their values."""
242
+ from yuho.ast import nodes
243
+
244
+ env = {}
245
+
246
+ # Add struct types to environment
247
+ for struct_def in ast.type_defs:
248
+ env[struct_def.name] = {"_type": "struct_def", "fields": {f.name: f for f in struct_def.fields}}
249
+
250
+ # Add variables to environment
251
+ for var in ast.variables:
252
+ if var.value:
253
+ env[var.name] = _evaluate_expr(var.value, env)
254
+
255
+ return env
256
+
257
+
258
+ def _evaluate_expr(expr, env: dict):
259
+ """Evaluate an expression in the given environment."""
260
+ from yuho.ast import nodes
261
+
262
+ if isinstance(expr, nodes.BoolLit):
263
+ return expr.value
264
+ elif isinstance(expr, nodes.IntLit):
265
+ return expr.value
266
+ elif isinstance(expr, nodes.FloatLit):
267
+ return expr.value
268
+ elif isinstance(expr, nodes.StringLit):
269
+ return expr.value
270
+ elif isinstance(expr, nodes.IdentifierNode):
271
+ return env.get(expr.name, f"<unbound:{expr.name}>")
272
+ elif isinstance(expr, nodes.FieldAccessNode):
273
+ base = _evaluate_expr(expr.base, env)
274
+ if isinstance(base, dict):
275
+ return base.get(expr.field_name, f"<no field:{expr.field_name}>")
276
+ # Handle enum-style access like Party.Accused
277
+ if isinstance(base, str) and base.startswith("<unbound:"):
278
+ # This is an enum reference like ConsequenceDefinition.Murder
279
+ type_name = base.replace("<unbound:", "").replace(">", "")
280
+ return f"{type_name}.{expr.field_name}"
281
+ return f"<field access error>"
282
+ elif isinstance(expr, nodes.StructLiteralNode):
283
+ result = {"_struct_name": expr.struct_name}
284
+ for fa in expr.field_values:
285
+ result[fa.name] = _evaluate_expr(fa.value, env)
286
+ return result
287
+ elif isinstance(expr, nodes.BinaryExprNode):
288
+ left = _evaluate_expr(expr.left, env)
289
+ right = _evaluate_expr(expr.right, env)
290
+ if expr.operator == "==":
291
+ return left == right
292
+ elif expr.operator == "!=":
293
+ return left != right
294
+ elif expr.operator == "&&":
295
+ return left and right
296
+ elif expr.operator == "||":
297
+ return left or right
298
+ return f"<binary:{expr.operator}>"
299
+ elif isinstance(expr, nodes.PassExprNode):
300
+ return None
301
+ else:
302
+ return f"<unevaluated:{type(expr).__name__}>"
303
+
304
+
305
+ def _evaluate_assertion(assertion, env: dict, verbose: bool) -> tuple:
306
+ """Evaluate an assertion and return (passed, error_message)."""
307
+ from yuho.ast import nodes
308
+
309
+ condition = assertion.condition
310
+
311
+ # Handle equality assertions like: assert var.field == ExpectedValue
312
+ if isinstance(condition, nodes.BinaryExprNode) and condition.operator == "==":
313
+ left = _evaluate_expr(condition.left, env)
314
+ right = _evaluate_expr(condition.right, env)
315
+
316
+ if left == right:
317
+ return (True, "")
318
+ else:
319
+ return (False, f"expected {right}, got {left}")
320
+
321
+ # Handle simple boolean assertions
322
+ result = _evaluate_expr(condition, env)
323
+ if result is True:
324
+ return (True, "")
325
+ elif result is False:
326
+ return (False, "assertion evaluated to FALSE")
327
+ else:
328
+ # Non-boolean result, check for truthiness
329
+ if result:
330
+ return (True, "")
331
+ return (False, f"assertion evaluated to: {result}")
332
+
333
+
334
+ def _generate_html_coverage_report(report, output_path: Path) -> None:
335
+ """Generate an HTML coverage report."""
336
+ report_dict = report.to_dict()
337
+ summary = report_dict["summary"]
338
+ statutes = report_dict["statutes"]
339
+
340
+ html = f"""<!DOCTYPE html>
341
+ <html lang="en">
342
+ <head>
343
+ <meta charset="UTF-8">
344
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
345
+ <title>Yuho Coverage Report</title>
346
+ <style>
347
+ body {{
348
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
349
+ margin: 0;
350
+ padding: 20px;
351
+ background: #f5f5f5;
352
+ }}
353
+ .container {{
354
+ max-width: 1200px;
355
+ margin: 0 auto;
356
+ }}
357
+ h1 {{
358
+ color: #333;
359
+ border-bottom: 2px solid #007bff;
360
+ padding-bottom: 10px;
361
+ }}
362
+ .summary {{
363
+ background: white;
364
+ padding: 20px;
365
+ border-radius: 8px;
366
+ margin-bottom: 20px;
367
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
368
+ }}
369
+ .summary-grid {{
370
+ display: grid;
371
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
372
+ gap: 15px;
373
+ }}
374
+ .metric {{
375
+ text-align: center;
376
+ padding: 15px;
377
+ background: #f8f9fa;
378
+ border-radius: 4px;
379
+ }}
380
+ .metric-value {{
381
+ font-size: 2em;
382
+ font-weight: bold;
383
+ color: #007bff;
384
+ }}
385
+ .metric-label {{
386
+ color: #666;
387
+ font-size: 0.9em;
388
+ }}
389
+ .statute {{
390
+ background: white;
391
+ padding: 20px;
392
+ border-radius: 8px;
393
+ margin-bottom: 15px;
394
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
395
+ }}
396
+ .statute-header {{
397
+ display: flex;
398
+ justify-content: space-between;
399
+ align-items: center;
400
+ margin-bottom: 15px;
401
+ }}
402
+ .statute-title {{
403
+ font-size: 1.2em;
404
+ font-weight: bold;
405
+ color: #333;
406
+ }}
407
+ .coverage-badge {{
408
+ padding: 5px 15px;
409
+ border-radius: 20px;
410
+ font-weight: bold;
411
+ color: white;
412
+ }}
413
+ .coverage-high {{ background: #28a745; }}
414
+ .coverage-medium {{ background: #ffc107; color: #333; }}
415
+ .coverage-low {{ background: #dc3545; }}
416
+ .elements-table {{
417
+ width: 100%;
418
+ border-collapse: collapse;
419
+ }}
420
+ .elements-table th, .elements-table td {{
421
+ padding: 10px;
422
+ text-align: left;
423
+ border-bottom: 1px solid #eee;
424
+ }}
425
+ .elements-table th {{
426
+ background: #f8f9fa;
427
+ font-weight: 600;
428
+ }}
429
+ .status-covered {{
430
+ color: #28a745;
431
+ font-weight: bold;
432
+ }}
433
+ .status-uncovered {{
434
+ color: #dc3545;
435
+ font-weight: bold;
436
+ }}
437
+ .generated {{
438
+ text-align: center;
439
+ color: #999;
440
+ font-size: 0.9em;
441
+ margin-top: 30px;
442
+ }}
443
+ </style>
444
+ </head>
445
+ <body>
446
+ <div class="container">
447
+ <h1>Yuho Coverage Report</h1>
448
+
449
+ <div class="summary">
450
+ <h2>Summary</h2>
451
+ <div class="summary-grid">
452
+ <div class="metric">
453
+ <div class="metric-value">{summary['overall_coverage']}</div>
454
+ <div class="metric-label">Overall Coverage</div>
455
+ </div>
456
+ <div class="metric">
457
+ <div class="metric-value">{summary['total_statutes']}</div>
458
+ <div class="metric-label">Statutes</div>
459
+ </div>
460
+ <div class="metric">
461
+ <div class="metric-value">{summary['passed_tests']}/{summary['total_tests']}</div>
462
+ <div class="metric-label">Tests Passed</div>
463
+ </div>
464
+ </div>
465
+ </div>
466
+ """
467
+
468
+ for section, data in statutes.items():
469
+ coverage_pct = float(data['overall_coverage'].rstrip('%'))
470
+ if coverage_pct >= 80:
471
+ badge_class = "coverage-high"
472
+ elif coverage_pct >= 50:
473
+ badge_class = "coverage-medium"
474
+ else:
475
+ badge_class = "coverage-low"
476
+
477
+ html += f"""
478
+ <div class="statute">
479
+ <div class="statute-header">
480
+ <span class="statute-title">Section {section}: {data['title']}</span>
481
+ <span class="coverage-badge {badge_class}">{data['overall_coverage']}</span>
482
+ </div>
483
+ <table class="elements-table">
484
+ <thead>
485
+ <tr>
486
+ <th>Element</th>
487
+ <th>Type</th>
488
+ <th>Status</th>
489
+ <th>Tests</th>
490
+ </tr>
491
+ </thead>
492
+ <tbody>
493
+ """
494
+ for elem_name, elem_data in data['elements'].items():
495
+ status_class = "status-covered" if elem_data['covered'] else "status-uncovered"
496
+ status_text = "COVERED" if elem_data['covered'] else "NOT COVERED"
497
+ html += f"""
498
+ <tr>
499
+ <td>{elem_name.split(':')[1] if ':' in elem_name else elem_name}</td>
500
+ <td>{elem_data['type']}</td>
501
+ <td class="{status_class}">{status_text}</td>
502
+ <td>{elem_data['test_count']}</td>
503
+ </tr>
504
+ """
505
+
506
+ penalty_status = "COVERED" if data['penalty_covered'] else "NOT COVERED"
507
+ penalty_class = "status-covered" if data['penalty_covered'] else "status-uncovered"
508
+ html += f"""
509
+ <tr>
510
+ <td>Penalty</td>
511
+ <td>penalty</td>
512
+ <td class="{penalty_class}">{penalty_status}</td>
513
+ <td>-</td>
514
+ </tr>
515
+ </tbody>
516
+ </table>
517
+ </div>
518
+ """
519
+
520
+ html += f"""
521
+ <p class="generated">Generated by Yuho v5 on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
522
+ </div>
523
+ </body>
524
+ </html>
525
+ """
526
+
527
+ output_path.write_text(html)
528
+
@@ -0,0 +1,121 @@
1
+ """
2
+ Transpile command - convert Yuho files to other formats.
3
+ """
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Optional, List
9
+
10
+ import click
11
+
12
+ from yuho.parser import Parser
13
+ from yuho.ast import ASTBuilder
14
+ from yuho.transpile import TranspileTarget, get_transpiler
15
+ from yuho.cli.error_formatter import Colors, colorize
16
+
17
+
18
+ ALL_TARGETS = ["json", "jsonld", "english", "mermaid", "alloy"]
19
+
20
+
21
+ def run_transpile(
22
+ file: str,
23
+ target: str = "json",
24
+ output: Optional[str] = None,
25
+ output_dir: Optional[str] = None,
26
+ all_targets: bool = False,
27
+ json_output: bool = False,
28
+ verbose: bool = False
29
+ ) -> None:
30
+ """
31
+ Transpile a Yuho file to another format.
32
+
33
+ Args:
34
+ file: Path to the .yh file
35
+ target: Target format (json, jsonld, english, mermaid, alloy)
36
+ output: Output file path
37
+ output_dir: Output directory for multiple files
38
+ all_targets: Generate all targets
39
+ json_output: Output metadata as JSON
40
+ verbose: Enable verbose output
41
+ """
42
+ file_path = Path(file)
43
+
44
+ if verbose:
45
+ click.echo(f"Parsing {file_path}...")
46
+
47
+ # Parse and build AST
48
+ parser = Parser()
49
+ try:
50
+ result = parser.parse_file(file_path)
51
+ except FileNotFoundError:
52
+ click.echo(colorize(f"error: File not found: {file}", Colors.RED), err=True)
53
+ sys.exit(1)
54
+
55
+ if result.errors:
56
+ click.echo(colorize(f"error: Parse errors in {file}", Colors.RED), err=True)
57
+ for err in result.errors:
58
+ click.echo(f" {err.location}: {err.message}", err=True)
59
+ sys.exit(1)
60
+
61
+ builder = ASTBuilder(result.source, str(file_path))
62
+ ast = builder.build(result.root_node)
63
+
64
+ # Determine targets
65
+ targets: List[str] = ALL_TARGETS if all_targets else [target]
66
+
67
+ # Determine output directory
68
+ if output_dir:
69
+ out_dir = Path(output_dir)
70
+ out_dir.mkdir(parents=True, exist_ok=True)
71
+ else:
72
+ out_dir = None
73
+
74
+ results = []
75
+
76
+ for tgt in targets:
77
+ if verbose:
78
+ click.echo(f"Transpiling to {tgt}...")
79
+
80
+ try:
81
+ transpile_target = TranspileTarget.from_string(tgt)
82
+ transpiler = get_transpiler(transpile_target)
83
+ output_text = transpiler.transpile(ast)
84
+ except ValueError as e:
85
+ click.echo(colorize(f"error: {e}", Colors.RED), err=True)
86
+ sys.exit(1)
87
+
88
+ # Determine output path
89
+ if all_targets or output_dir:
90
+ # Multiple targets -> use directory
91
+ if out_dir:
92
+ out_path = out_dir / f"{file_path.stem}{transpile_target.file_extension}"
93
+ else:
94
+ out_path = file_path.parent / f"{file_path.stem}{transpile_target.file_extension}"
95
+
96
+ out_path.write_text(output_text, encoding="utf-8")
97
+ results.append({"target": tgt, "output": str(out_path)})
98
+
99
+ if not json_output:
100
+ click.echo(f" -> {out_path}")
101
+
102
+ elif output:
103
+ # Single target with explicit output
104
+ out_path = Path(output)
105
+ out_path.parent.mkdir(parents=True, exist_ok=True)
106
+ out_path.write_text(output_text, encoding="utf-8")
107
+ results.append({"target": tgt, "output": str(out_path)})
108
+
109
+ if not json_output:
110
+ click.echo(f" -> {out_path}")
111
+
112
+ else:
113
+ # Single target to stdout
114
+ print(output_text)
115
+ results.append({"target": tgt, "output": "stdout"})
116
+
117
+ if json_output:
118
+ print(json.dumps({
119
+ "source": str(file_path),
120
+ "results": results,
121
+ }, indent=2))