tlaplus-cli 0.5.0__tar.gz → 0.6.0__tar.gz

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 (81) hide show
  1. {tlaplus_cli-0.5.0/src/tlaplus_cli.egg-info → tlaplus_cli-0.6.0}/PKG-INFO +2 -1
  2. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/pyproject.toml +2 -1
  3. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/tlc.py +51 -31
  4. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/config/schema.py +1 -0
  5. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/resources/default_config.yaml +1 -0
  6. tlaplus_cli-0.6.0/src/tlaplus_cli/tlc/formatter.py +409 -0
  7. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/tlc/models.py +2 -0
  8. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/tlc/parser.py +2 -0
  9. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/tlc/runner.py +36 -12
  10. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0/src/tlaplus_cli.egg-info}/PKG-INFO +2 -1
  11. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli.egg-info/SOURCES.txt +1 -0
  12. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli.egg-info/requires.txt +1 -0
  13. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/LICENSE +0 -0
  14. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/MANIFEST.in +0 -0
  15. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/README.md +0 -0
  16. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/setup.cfg +0 -0
  17. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/__init__.py +0 -0
  18. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cache/__init__.py +0 -0
  19. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cache/github.py +0 -0
  20. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cli.py +0 -0
  21. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/__init__.py +0 -0
  22. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/check_java.py +0 -0
  23. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/config/__init__.py +0 -0
  24. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/config/edit.py +0 -0
  25. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/config/list.py +0 -0
  26. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/fetch_cache/__init__.py +0 -0
  27. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/fetch_cache/clear.py +0 -0
  28. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/modules/__init__.py +0 -0
  29. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/modules/build.py +0 -0
  30. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/modules/install.py +0 -0
  31. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/modules/lib.py +0 -0
  32. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/modules/path.py +0 -0
  33. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/tools/__init__.py +0 -0
  34. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/tools/dir.py +0 -0
  35. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/tools/install.py +0 -0
  36. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/tools/list.py +0 -0
  37. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/tools/meta/__init__.py +0 -0
  38. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/tools/meta/sync.py +0 -0
  39. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/tools/path.py +0 -0
  40. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/tools/pin.py +0 -0
  41. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/tools/uninstall.py +0 -0
  42. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/cmd/tools/upgrade.py +0 -0
  43. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/config/__init__.py +0 -0
  44. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/config/loader.py +0 -0
  45. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/java/__init__.py +0 -0
  46. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/java/classpath.py +0 -0
  47. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/java/inspector.py +0 -0
  48. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/project/__init__.py +0 -0
  49. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/project/core.py +0 -0
  50. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/py.typed +0 -0
  51. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/resources/__init__.py +0 -0
  52. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/tlc/__init__.py +0 -0
  53. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/tlc/codes.py +0 -0
  54. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/tlc/compiler.py +0 -0
  55. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/tlc/sany.py +0 -0
  56. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/tlc/values.py +0 -0
  57. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/ui.py +0 -0
  58. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/versioning/__init__.py +0 -0
  59. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/versioning/api.py +0 -0
  60. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/versioning/downloader.py +0 -0
  61. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/versioning/metadata.py +0 -0
  62. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/versioning/paths.py +0 -0
  63. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/versioning/resolver.py +0 -0
  64. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli/versioning/schema.py +0 -0
  65. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli.egg-info/dependency_links.txt +0 -0
  66. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli.egg-info/entry_points.txt +0 -0
  67. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/src/tlaplus_cli.egg-info/top_level.txt +0 -0
  68. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/tests/test_build_both.py +0 -0
  69. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/tests/test_build_modules_path.py +0 -0
  70. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/tests/test_cli.py +0 -0
  71. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/tests/test_config.py +0 -0
  72. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/tests/test_config_suite.py +0 -0
  73. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/tests/test_modules_build_lib.py +0 -0
  74. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/tests/test_modules_build_show_command.py +0 -0
  75. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/tests/test_modules_install_community.py +0 -0
  76. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/tests/test_modules_lib.py +0 -0
  77. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/tests/test_modules_path.py +0 -0
  78. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/tests/test_modules_path_resolved.py +0 -0
  79. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/tests/test_settings.py +0 -0
  80. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/tests/test_tlc_modules_path.py +0 -0
  81. {tlaplus_cli-0.5.0 → tlaplus_cli-0.6.0}/tests/test_tlc_show_command.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tlaplus-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: TLA+ tools: download TLC, compile custom modules, run model checker
5
5
  Author-email: Denis Nikolskiy <codeomatics@gmail.com>
6
6
  License-Expression: MIT
@@ -25,6 +25,7 @@ Requires-Dist: pyyaml<7.0,>=6.0
25
25
  Requires-Dist: platformdirs<5.0,>=4.0
26
26
  Requires-Dist: pydantic<3.0.0,>=2.12.5
27
27
  Requires-Dist: truststore<1.0.0,>=0.9.0
28
+ Requires-Dist: rich<14.0.0,>=13.7.0
28
29
  Dynamic: license-file
29
30
 
30
31
  # TLA+ CLI
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tlaplus-cli"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "TLA+ tools: download TLC, compile custom modules, run model checker"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -15,6 +15,7 @@ dependencies = [
15
15
  "platformdirs>=4.0,<5.0",
16
16
  "pydantic>=2.12.5,<3.0.0",
17
17
  "truststore>=0.9.0,<1.0.0",
18
+ "rich>=13.7.0,<14.0.0",
18
19
  ]
19
20
  authors = [
20
21
  {name = "Denis Nikolskiy", email = "codeomatics@gmail.com"}
@@ -1,11 +1,14 @@
1
1
  import typer
2
+ from rich.console import Console
3
+ from rich.live import Live
2
4
 
3
5
  from tlaplus_cli import ui
4
6
  from tlaplus_cli.config.loader import load_config
5
7
  from tlaplus_cli.java.classpath import ClasspathResolver
6
8
  from tlaplus_cli.project import find_project_root
7
9
  from tlaplus_cli.tlc.compiler import get_tlc_jar_path
8
- from tlaplus_cli.tlc.models import CheckState, CheckStatus, ModelCheckResult
10
+ from tlaplus_cli.tlc.formatter import TlcFormatter
11
+ from tlaplus_cli.tlc.models import CheckState, ModelCheckResult
9
12
  from tlaplus_cli.tlc.runner import (
10
13
  build_tlc_command,
11
14
  get_tlc_version,
@@ -31,18 +34,6 @@ def version_callback(value: bool) -> None:
31
34
  raise typer.Exit(0)
32
35
 
33
36
 
34
- def print_progress(result: ModelCheckResult) -> None:
35
- """Callback to print progress from ModelCheckResult."""
36
- if result.status == CheckStatus.SuccessorStatesComputing and result.initial_states_stat:
37
- last_stat = result.initial_states_stat[-1]
38
- # Use carriage return to update the same line for progress
39
- msg = f"Progress: {last_stat.total} states generated, {last_stat.distinct} distinct states found"
40
- typer.echo(f"\r{msg}", err=True, nl=False)
41
- elif result.status == CheckStatus.Finished:
42
- # Final newline after progress
43
- typer.echo("", err=True)
44
-
45
-
46
37
  def _show_diagnostic_info(spec: str, spec_name: str) -> None:
47
38
  """Show diagnostic info before running TLC."""
48
39
  version_str = get_tlc_version() or "unknown"
@@ -68,7 +59,8 @@ def _show_diagnostic_info(spec: str, spec_name: str) -> None:
68
59
 
69
60
  # PLR0912: This command handler orchestrates multiple output types (errors, warnings, results, progress);
70
61
  # splitting it would fragment the CLI's primary user-facing logic.
71
- def tlc( # noqa: PLR0912
62
+ # PLR0915: Too many statements.
63
+ def tlc( # noqa: PLR0912, PLR0915
72
64
  spec: str = typer.Argument(help="Name of the TLA+ specification (without .tla extension)."),
73
65
  # Typer requires the parameter in the signature to register the CLI option;
74
66
  # since the logic is in the eager callback, the value is not used here.
@@ -80,49 +72,69 @@ def tlc( # noqa: PLR0912
80
72
  is_eager=True,
81
73
  ),
82
74
  show_command: bool = typer.Option(False, "--show-command", help="Print the command instead of executing it."),
75
+ _refresh_interval: float | None = typer.Option(
76
+ None,
77
+ "--refresh-interval",
78
+ help="Refresh interval for the output (seconds).",
79
+ ),
80
+ coverage: bool = typer.Option(False, "--coverage", help="Enable code coverage visualization."),
83
81
  ) -> None:
84
82
  """Run TLC model checker on a TLA+ specification."""
85
83
  # version argument is handled by version_callback (is_eager=True)
86
84
  try:
87
- _, spec_name = resolve_spec_file(spec)
85
+ _, _spec_name = resolve_spec_file(spec)
88
86
  except FileNotFoundError as e:
89
87
  ui.error(str(e))
90
88
  raise typer.Exit(1) from None
91
89
 
92
90
  if show_command:
93
- try:
94
- cmd = build_tlc_command(spec)
95
- except FileNotFoundError as e:
96
- ui.error(str(e))
91
+ cmd = build_tlc_command(spec)
92
+ if coverage:
93
+ cmd.extend(["-coverage", "1"])
94
+ if not cmd:
97
95
  raise typer.Exit(1) from None
98
96
  typer.echo(" ".join(cmd))
99
97
  raise typer.Exit(0)
100
98
 
101
- _show_diagnostic_info(spec, spec_name)
99
+ tlc_version = get_tlc_version() or "unknown"
100
+ formatter = TlcFormatter(tlc_version=tlc_version)
101
+ layout = formatter.create_layout()
102
102
 
103
103
  try:
104
- # We need the parser to get the final result, so we'll wrap run_tlc
105
- # or capture the result from the last callback.
106
104
  final_result: list[ModelCheckResult] = []
107
105
 
108
- def wrapped_callback(res: ModelCheckResult) -> None:
109
- print_progress(res)
110
- if not final_result:
111
- final_result.append(res)
112
- else:
113
- final_result[0] = res
106
+ with Live(layout, refresh_per_second=4, screen=False):
107
+
108
+ def wrapped_callback(res: ModelCheckResult) -> None:
109
+ formatter.update(layout, res)
110
+
111
+ if not final_result:
112
+ final_result.append(res)
113
+ else:
114
+ final_result[0] = res
114
115
 
115
- exit_code = run_tlc(spec, callback=wrapped_callback)
116
+ exit_code = run_tlc(spec, callback=wrapped_callback, coverage=coverage)
116
117
 
117
118
  if final_result:
118
119
  res = final_result[0]
120
+
121
+ if res.sany_errors:
122
+ console = Console()
123
+ spec_file, _ = resolve_spec_file(spec)
124
+ for sany_err in res.sany_errors:
125
+ console.print(formatter.render_sany_error(sany_err, spec_file))
126
+
119
127
  if res.state == CheckState.Success:
120
128
  ui.success("Model checking completed successfully.")
121
129
  elif res.state == CheckState.Error:
122
130
  ui.error(f"Model checking failed with {len(res.errors)} error(s).")
123
131
  for err in res.errors:
124
- for line in err.lines:
125
- typer.echo(f" {line}", err=True)
132
+ if err.error_trace:
133
+ console = Console()
134
+ console.print(formatter.render_error_trace(err.error_trace))
135
+ else:
136
+ for line in err.lines:
137
+ typer.echo(f" {line}", err=True)
126
138
 
127
139
  if res.warnings:
128
140
  ui.warn(f"Found {len(res.warnings)} warning(s).")
@@ -130,6 +142,14 @@ def tlc( # noqa: PLR0912
130
142
  for line in warn_info.lines:
131
143
  typer.echo(f" {line}", err=True)
132
144
 
145
+ if res.coverage_stat:
146
+ console = Console()
147
+ console.print(formatter.render_coverage_table(res.coverage_stat))
148
+
149
+ # Show inline coverage for the main spec
150
+ spec_file, _ = resolve_spec_file(spec)
151
+ console.print(formatter.render_inline_coverage(res.coverage_stat, spec_file))
152
+
133
153
  if res.duration:
134
154
  ui.info(f"Duration: {res.duration}ms")
135
155
  except FileNotFoundError as e:
@@ -26,6 +26,7 @@ class WorkspaceConfig(BaseModel):
26
26
  class TlcConfig(BaseModel):
27
27
  java_class: str = "tlc2.TLC"
28
28
  overrides_class: str = "tlc2.overrides.TLCOverrides"
29
+ refresh_interval: float = 1.0
29
30
 
30
31
 
31
32
  class JavaConfig(BaseModel):
@@ -19,6 +19,7 @@ workspace:
19
19
  tlc:
20
20
  java_class: tlc2.TLC
21
21
  overrides_class: tlc2.overrides.TLCOverrides
22
+ refresh_interval: 1.0
22
23
 
23
24
  java:
24
25
  min_version: 11
@@ -0,0 +1,409 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from rich.align import Align
7
+ from rich.console import Group
8
+ from rich.layout import Layout
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+ from rich.tree import Tree
12
+
13
+ from tlaplus_cli.tlc.models import (
14
+ CheckState,
15
+ CoverageItem,
16
+ ErrorTraceItem,
17
+ ModelCheckResult,
18
+ TlaValue,
19
+ )
20
+ from tlaplus_cli.tlc.sany import SanyMessage
21
+
22
+
23
+ @dataclass
24
+ class _Section:
25
+ """A named section in the TLC display."""
26
+
27
+ name: str
28
+ renderable: Any = field(default_factory=lambda: Text(""))
29
+ size: int | None = None
30
+
31
+ def update(self, renderable: Any) -> None:
32
+ """Update the section content."""
33
+ self.renderable = renderable
34
+
35
+ def __rich__(self) -> Any:
36
+ return self.renderable
37
+
38
+
39
+ class TlcDisplay:
40
+ """A dynamic display that grows as content is added."""
41
+
42
+ def __init__(self) -> None:
43
+ self.children: list[_Section] = []
44
+ self.renderable: Any = Text("")
45
+
46
+ def __getitem__(self, name: str) -> _Section:
47
+ for child in self.children:
48
+ if child.name == name:
49
+ return child
50
+ raise KeyError(name)
51
+
52
+ def update(self, renderable: Any) -> None:
53
+ """Update root renderable (used before splitting)."""
54
+ self.renderable = renderable
55
+
56
+ def split_column(self, *sections: _Section) -> None:
57
+ """Set the active sections."""
58
+ self.children = list(sections)
59
+
60
+ def __rich__(self) -> Group:
61
+ if not self.children:
62
+ return Group(self.renderable)
63
+ return Group(*self.children)
64
+
65
+
66
+ @dataclass
67
+ class ValueDiff:
68
+ key: str | int
69
+ value: str
70
+ tag: str # "added", "modified", "unchanged"
71
+ children: list["ValueDiff"] = field(default_factory=list)
72
+
73
+
74
+ class LogDeduplicator:
75
+ """Groups consecutive identical logs into a single line with a count badge."""
76
+
77
+ def __init__(self) -> None:
78
+ self.last_line: str | None = None
79
+ self.count: int = 1
80
+
81
+ def process(self, line: str) -> str:
82
+ stripped_line = line.strip()
83
+ if stripped_line == self.last_line:
84
+ self.count += 1
85
+ return f"{stripped_line} ({self.count})"
86
+
87
+ self.last_line = stripped_line
88
+ self.count = 1
89
+ return stripped_line
90
+
91
+
92
+ class TlcFormatter:
93
+ """Generates Rich layout for TLC model checking."""
94
+
95
+ def __init__(self, tlc_version: str = "unknown") -> None:
96
+ self.info_dedup = LogDeduplicator()
97
+ self.tlc_version = tlc_version
98
+
99
+ def render_sany_error(self, error: SanyMessage, spec_path: Path) -> Text:
100
+ """Render SANY error with source code highlight."""
101
+ result = Text()
102
+ result.append(f"✖ SANY Error: {error.message}\n", style="bold red")
103
+ result.append(f" file://{spec_path}#L{error.range_start_line}\n\n", style="dim")
104
+
105
+ if error.range_start_line is not None:
106
+ try:
107
+ with spec_path.open() as f:
108
+ lines = f.readlines()
109
+
110
+ start_line = max(0, error.range_start_line - 3)
111
+ end_line = min(len(lines), (error.range_end_line or error.range_start_line) + 2)
112
+
113
+ for i in range(start_line, end_line):
114
+ line_num = i + 1
115
+ line_content = lines[i].rstrip()
116
+ if line_num == error.range_start_line:
117
+ result.append(f"{line_num:4} │ ", style="bold")
118
+ result.append(f"{line_content}\n")
119
+ # Add caret
120
+ col_start = error.range_start_col or 1
121
+ col_end = error.range_end_col or col_start + 1
122
+ caret_len = max(1, col_end - col_start)
123
+ caret_line = " │ " + " " * (col_start - 1) + "▲" * caret_len
124
+ result.append(f"{caret_line}\n", style="bold red")
125
+ msg = f"line {error.range_start_line}, col {col_start} to {col_end}"
126
+ result.append(f" │ {' ' * (col_start - 1)}└── Error here: {msg}\n", style="dim")
127
+ else:
128
+ result.append(f"{line_num:4} │ {line_content}\n", style="dim")
129
+
130
+ except OSError:
131
+ result.append(" (Source code unavailable)\n", style="dim")
132
+
133
+ return result
134
+
135
+ def diff_values(self, old_val: TlaValue | None, new_val: TlaValue) -> ValueDiff:
136
+ """Recursively diff two TlaValue trees."""
137
+ if old_val is None:
138
+ return ValueDiff(
139
+ key=new_val.key,
140
+ value=new_val.value,
141
+ tag="added",
142
+ children=[self.diff_values(None, child) for child in new_val.children],
143
+ )
144
+
145
+ if old_val.value == new_val.value:
146
+ return ValueDiff(
147
+ key=new_val.key,
148
+ value=new_val.value,
149
+ tag="unchanged",
150
+ children=[
151
+ self.diff_values(old_child, new_child)
152
+ for old_child, new_child in zip(old_val.children, new_val.children, strict=False)
153
+ ],
154
+ )
155
+
156
+ # Values are different, but maybe some children are the same
157
+ tag = "modified"
158
+ children = []
159
+ # Match children by key
160
+ old_children_map = {c.key: c for c in old_val.children}
161
+ for new_child in new_val.children:
162
+ old_child = old_children_map.get(new_child.key)
163
+ children.append(self.diff_values(old_child, new_child))
164
+
165
+ return ValueDiff(
166
+ key=new_val.key,
167
+ value=new_val.value,
168
+ tag=tag,
169
+ children=children,
170
+ )
171
+
172
+ def render_value_diff(self, diff: ValueDiff, tree: Tree | None = None) -> Tree:
173
+ """Render a ValueDiff into a Rich Tree."""
174
+ style = ""
175
+ prefix = ""
176
+ if diff.tag == "added":
177
+ style = "green"
178
+ prefix = "[bold green]A[/] "
179
+ elif diff.tag == "modified":
180
+ style = "yellow"
181
+ prefix = "[bold yellow]M[/] "
182
+ elif diff.tag == "unchanged":
183
+ style = "dim"
184
+
185
+ label = Text()
186
+ if prefix:
187
+ label.append(Text.from_markup(prefix))
188
+ label.append(f"{diff.key} = ")
189
+ label.append(diff.value, style=style)
190
+
191
+ node = Tree(label) if tree is None else tree.add(label)
192
+
193
+ for child in diff.children:
194
+ self.render_value_diff(child, node)
195
+
196
+ return node
197
+
198
+ def render_error_trace(self, trace: list[ErrorTraceItem]) -> Tree:
199
+ """Render a full error trace as a Tree."""
200
+ root = Tree("Counterexample (Error Trace)")
201
+ last_variables: dict[str | int, TlaValue] = {}
202
+
203
+ for item in trace:
204
+ state_label = Text(f"State {item.num}: {item.action}")
205
+ if item.module:
206
+ state_label.append(f" in {item.module}", style="italic")
207
+
208
+ state_node = root.add(state_label)
209
+
210
+ # Sort variables to ensure consistent order
211
+ sorted_vars = sorted(item.variables, key=lambda v: str(v.key))
212
+
213
+ for var in sorted_vars:
214
+ old_var = last_variables.get(var.key)
215
+ diff = self.diff_values(old_var, var)
216
+ self.render_value_diff(diff, state_node)
217
+ last_variables[var.key] = var
218
+
219
+ return root
220
+
221
+ def render_coverage_table(self, coverage: list[CoverageItem]) -> Table:
222
+ """Render action coverage table."""
223
+ table = Table(title="Action Coverage", expand=True)
224
+ table.add_column("Module")
225
+ table.add_column("Action")
226
+ table.add_column("Total States", justify="right")
227
+ table.add_column("Unique States", justify="right")
228
+
229
+ for item in coverage:
230
+ table.add_row(
231
+ item.module,
232
+ item.action,
233
+ f"{item.total:,}",
234
+ f"{item.distinct:,}",
235
+ )
236
+ return table
237
+
238
+ def render_inline_coverage(self, coverage: list[CoverageItem], spec_path: Path) -> Text:
239
+ """Render inline spec coverage."""
240
+ result = Text()
241
+ result.append("\nInline Coverage View:\n", style="bold")
242
+ result.append(" Coverage Line │ Source Code\n", style="dim")
243
+ result.append("─────────── ─────┼─────────────────────────────────────────────\n", style="dim")
244
+
245
+ try:
246
+ with spec_path.open() as f:
247
+ lines = f.readlines()
248
+
249
+ # Map lines to coverage
250
+ line_coverage: dict[int, list[CoverageItem]] = {}
251
+ for item in coverage:
252
+ if item.module == spec_path.stem:
253
+ for line_idx in range(item.range_start_line, item.range_end_line + 1):
254
+ if line_idx not in line_coverage:
255
+ line_coverage[line_idx] = []
256
+ line_coverage[line_idx].append(item)
257
+
258
+ for i, line_content in enumerate(lines):
259
+ line_num = i + 1
260
+ cov_items = line_coverage.get(line_num, [])
261
+
262
+ cov_str = ""
263
+ style = ""
264
+ line_text = line_content.rstrip()
265
+ if cov_items:
266
+ max_distinct = max(item.distinct for item in cov_items)
267
+ cov_str = f"{max_distinct:,}"
268
+ if max_distinct == 0:
269
+ style = "bold red"
270
+ line_text += " <-- UNREACHED"
271
+
272
+ result.append(f"{cov_str:>11} {line_num:5} │ ", style="dim")
273
+ result.append(f"{line_text}\n", style=style)
274
+
275
+ except OSError:
276
+ result.append(" (Source code unavailable)\n", style="dim")
277
+
278
+ return result
279
+
280
+ def create_layout(self) -> Any:
281
+ """Create the initial layout structure with only the header."""
282
+ layout = TlcDisplay()
283
+ layout.update(Text(f"Starting TLC model checker (version {self.tlc_version}).", style="bold"))
284
+ return layout
285
+
286
+ def update(self, layout: Any, result: ModelCheckResult) -> None:
287
+ """Main update entry point that manages layout structure and content."""
288
+ self._ensure_layout_structure(layout, result)
289
+ self.update_header(layout, result)
290
+ if self._has_child(layout, "clock"):
291
+ self.update_clock(layout, result)
292
+ if self._has_child(layout, "logs"):
293
+ self.update_logs(layout, result)
294
+ if self._has_child(layout, "stats"):
295
+ self.update_stats(layout, result)
296
+
297
+ def _has_child(self, layout: Any, name: str) -> bool:
298
+ """Check if a layout has a child with the given name."""
299
+ return any(child.name == name for child in layout.children)
300
+
301
+ def _ensure_layout_structure(self, layout: Any, result: ModelCheckResult) -> None:
302
+ """Dynamically add sections as data becomes available."""
303
+ has_logs = bool(result.output_lines)
304
+ has_stats = bool(result.initial_states_stat)
305
+ has_clock = bool(result.start_date_time or result.process_info or result.duration)
306
+
307
+ # Build expected structure in desired order:
308
+ # 1. Header
309
+ # 2. Status Info (clock)
310
+ # 3. TLC instance info (logs)
311
+ # 4. State Space Progress (stats) - grows infinitely at the bottom
312
+ new_layouts = [_Section(name="header", size=2)]
313
+ if has_clock:
314
+ new_layouts.append(_Section(name="clock", size=7))
315
+ if has_logs:
316
+ new_layouts.append(_Section(name="logs"))
317
+ if has_stats:
318
+ new_layouts.append(_Section(name="stats"))
319
+
320
+ # Check if we need to update
321
+ current_names = [lay.name for lay in layout.children]
322
+ expected_names = [lay.name for lay in new_layouts]
323
+
324
+ if current_names != expected_names:
325
+ layout.split_column(*new_layouts)
326
+
327
+ def update_header(self, layout: Any, _result: ModelCheckResult) -> None:
328
+ """Update header."""
329
+ # _result is unused for now but kept for signature consistency
330
+ header_text = Text(f"Starting TLC model checker (version {self.tlc_version}).", style="bold")
331
+ if self._has_child(layout, "header"):
332
+ layout["header"].update(header_text)
333
+ else:
334
+ layout.update(header_text)
335
+
336
+ def update_stats(self, layout: Layout, result: ModelCheckResult) -> None:
337
+ table = Table(title="State Space Progress", expand=True)
338
+ table.add_column("Time")
339
+ table.add_column("Diameter", justify="right")
340
+ table.add_column("Found", justify="right")
341
+ table.add_column("Distinct", justify="right")
342
+ table.add_column("Queue", justify="right")
343
+
344
+ for item in result.initial_states_stat:
345
+ table.add_row(
346
+ item.timestamp,
347
+ str(item.diameter),
348
+ f"{item.total:,}",
349
+ f"{item.distinct:,}",
350
+ f"{item.queue_size:,}",
351
+ )
352
+ layout["stats"].update(table)
353
+
354
+ def update_logs(self, layout: Layout, result: ModelCheckResult) -> None:
355
+ """Update logs panel with TLC output."""
356
+ deduped_logs: list[str] = []
357
+ temp_dedup = LogDeduplicator()
358
+ for line in result.output_lines:
359
+ processed = temp_dedup.process(line)
360
+ if temp_dedup.count > 1 and deduped_logs:
361
+ deduped_logs[-1] = processed
362
+ else:
363
+ deduped_logs.append(processed)
364
+
365
+ log_lines = Text()
366
+ for line in deduped_logs[-10:]:
367
+ log_lines.append(f"{line}\n")
368
+
369
+ layout["logs"].update(Group(Align.center(Text("TLC instance info", style="bold")), log_lines))
370
+
371
+ def update_clock(self, layout: Layout, result: ModelCheckResult) -> None:
372
+ """Update info panel at the bottom with clock and TLC stats."""
373
+ layout["clock"].update(StatusInfoRenderable(result))
374
+
375
+
376
+ class StatusInfoRenderable:
377
+ """Renderable that computes elapsed time dynamically."""
378
+
379
+ def __init__(self, result: ModelCheckResult) -> None:
380
+ self.result = result
381
+
382
+ def __rich__(self) -> Group:
383
+ """Render the status info group."""
384
+ status_text = self.result.status.name
385
+ if self.result.state == CheckState.Stopped:
386
+ status_text = "canceled"
387
+
388
+ if not self.result.start_date_time:
389
+ elapsed_str = "00:00:00"
390
+ elapsed_style = "dim"
391
+ else:
392
+ if self.result.state == CheckState.Running:
393
+ elapsed = datetime.now() - self.result.start_date_time
394
+ else:
395
+ elapsed = (self.result.end_date_time or datetime.now()) - self.result.start_date_time
396
+ seconds = int(elapsed.total_seconds())
397
+ hours, remainder = divmod(seconds, 3600)
398
+ minutes, seconds = divmod(remainder, 60)
399
+ elapsed_str = f"{hours:02}:{minutes:02}:{seconds:02}"
400
+ elapsed_style = "bold cyan"
401
+
402
+ info_text = Text()
403
+ info_text.append(f"Elapsed time: {elapsed_str}\n", style=elapsed_style)
404
+ info_text.append(f"Status: {status_text}\n")
405
+
406
+ if self.result.state == CheckState.Running:
407
+ info_text.append("Stop with Ctrl + C", style="yellow")
408
+
409
+ return Group(Align.center(Text("Status Info", style="bold")), info_text)
@@ -6,6 +6,7 @@ Ported and adapted from ts-output-parser/model/check.ts.
6
6
  from dataclasses import dataclass, field
7
7
  from datetime import datetime
8
8
  from enum import Enum
9
+ from typing import Any
9
10
 
10
11
 
11
12
  class CheckState(Enum):
@@ -127,4 +128,5 @@ class ModelCheckResult:
127
128
  workers_count: int = 0
128
129
  collision_probability: str | None = None
129
130
  output_lines: list[str] = field(default_factory=list)
131
+ sany_errors: list[Any] = field(default_factory=list)
130
132
  trace_file_path: str | None = None
@@ -139,6 +139,8 @@ class TlcParser:
139
139
  if code == codes.TLC_STARTING.num:
140
140
  self._parse_starting(message.lines)
141
141
  self.result.status = CheckStatus.Starting
142
+ elif code == codes.TLC_VERSION.num:
143
+ self.result.process_info = "".join(message.lines).strip()
142
144
  elif code == codes.TLC_PROGRESS_STATS.num:
143
145
  self._parse_progress_stats(message.lines)
144
146
  self.result.status = CheckStatus.SuccessorStatesComputing
@@ -8,8 +8,9 @@ from tlaplus_cli.java import validate_java_version
8
8
  from tlaplus_cli.java.classpath import ClasspathResolver, ResolveMode
9
9
  from tlaplus_cli.project import find_project_root
10
10
  from tlaplus_cli.tlc.compiler import get_tlc_jar_path
11
- from tlaplus_cli.tlc.models import ModelCheckResult
11
+ from tlaplus_cli.tlc.models import CheckState, ModelCheckResult
12
12
  from tlaplus_cli.tlc.parser import TlcParser
13
+ from tlaplus_cli.tlc.sany import SanyParser
13
14
 
14
15
 
15
16
  def resolve_spec_file(spec: str) -> tuple[Path, str]:
@@ -66,7 +67,7 @@ def build_tlc_command(spec: str) -> list[str]:
66
67
  ]
67
68
 
68
69
 
69
- def run_tlc(spec: str, callback: Callable[[ModelCheckResult], None] | None = None) -> int:
70
+ def run_tlc(spec: str, callback: Callable[[ModelCheckResult], None] | None = None, coverage: bool = False) -> int: # noqa: PLR0912
70
71
  """Run TLC model checker on a TLA+ specification. Returns exit code."""
71
72
  config = load_config()
72
73
  validate_java_version(config.java.min_version)
@@ -76,12 +77,15 @@ def run_tlc(spec: str, callback: Callable[[ModelCheckResult], None] | None = Non
76
77
 
77
78
  # Ensure tool mode is enabled for structured output
78
79
  if "-tool" not in cmd:
79
- # Insert -tool after java class name or at some reasonable position
80
- # TLC usually expects options before the spec file name.
81
- # cmd is ["java", ..., "tlc2.TLC", "spec.tla"]
82
80
  cmd.insert(-1, "-tool")
83
81
 
82
+ if coverage and "-coverage" not in cmd:
83
+ # coverage expects a number of minutes between stats, default to 1
84
+ cmd.insert(-1, "-coverage")
85
+ cmd.insert(-1, "1")
86
+
84
87
  parser = TlcParser()
88
+ sany_parser = SanyParser()
85
89
 
86
90
  try:
87
91
  with subprocess.Popen(
@@ -92,15 +96,35 @@ def run_tlc(spec: str, callback: Callable[[ModelCheckResult], None] | None = Non
92
96
  text=True,
93
97
  bufsize=1,
94
98
  ) as process:
95
- if process.stdout:
96
- for line in process.stdout:
97
- if line.startswith("@!@!@"):
99
+ try:
100
+ if process.stdout:
101
+ for line in process.stdout:
102
+ if line.startswith("@!@!@"):
103
+ parser.process_line(line)
104
+ else:
105
+ sany_parser.process_line(line)
106
+ # We also pass non-SANY, non-TLC-tool-mode lines to the TLC parser
107
+ # so they end up in output_lines
108
+ parser.process_line(line)
109
+
110
+ if callback:
111
+ res = parser.get_result()
112
+ res.sany_errors = sany_parser.get_errors()
113
+ callback(res)
114
+ except KeyboardInterrupt:
115
+ # Stop the TLC process gracefully
116
+ process.terminate()
117
+ # Parse remaining output if any
118
+ if process.stdout:
119
+ for line in process.stdout:
98
120
  parser.process_line(line)
99
- else:
100
- print(line, end="", flush=True)
121
+ if callback:
122
+ callback(parser.get_result())
101
123
 
102
- if callback:
103
- callback(parser.get_result())
124
+ if callback:
125
+ res = parser.get_result()
126
+ res.state = CheckState.Stopped
127
+ callback(res)
104
128
 
105
129
  process.wait()
106
130
  return process.returncode
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tlaplus-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: TLA+ tools: download TLC, compile custom modules, run model checker
5
5
  Author-email: Denis Nikolskiy <codeomatics@gmail.com>
6
6
  License-Expression: MIT
@@ -25,6 +25,7 @@ Requires-Dist: pyyaml<7.0,>=6.0
25
25
  Requires-Dist: platformdirs<5.0,>=4.0
26
26
  Requires-Dist: pydantic<3.0.0,>=2.12.5
27
27
  Requires-Dist: truststore<1.0.0,>=0.9.0
28
+ Requires-Dist: rich<14.0.0,>=13.7.0
28
29
  Dynamic: license-file
29
30
 
30
31
  # TLA+ CLI
@@ -50,6 +50,7 @@ src/tlaplus_cli/resources/default_config.yaml
50
50
  src/tlaplus_cli/tlc/__init__.py
51
51
  src/tlaplus_cli/tlc/codes.py
52
52
  src/tlaplus_cli/tlc/compiler.py
53
+ src/tlaplus_cli/tlc/formatter.py
53
54
  src/tlaplus_cli/tlc/models.py
54
55
  src/tlaplus_cli/tlc/parser.py
55
56
  src/tlaplus_cli/tlc/runner.py
@@ -4,3 +4,4 @@ pyyaml<7.0,>=6.0
4
4
  platformdirs<5.0,>=4.0
5
5
  pydantic<3.0.0,>=2.12.5
6
6
  truststore<1.0.0,>=0.9.0
7
+ rich<14.0.0,>=13.7.0
File without changes
File without changes
File without changes
File without changes