@tishlang/tish-format 3.6.0 → 3.7.1

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 (36) hide show
  1. package/Cargo.toml +1 -0
  2. package/bin/tish-format +0 -0
  3. package/crates/tish/Cargo.toml +4 -1
  4. package/crates/tish/src/cli_help.rs +84 -0
  5. package/crates/tish/src/main.rs +93 -1
  6. package/crates/tish/tests/test_runner.rs +673 -0
  7. package/crates/tish_bytecode/src/compiler.rs +25 -4
  8. package/crates/tish_compile/src/resolve.rs +45 -12
  9. package/crates/tish_core/src/lib.rs +103 -11
  10. package/crates/tish_eval/src/eval.rs +16 -0
  11. package/crates/tish_eval/src/value_convert.rs +10 -0
  12. package/crates/tish_fmt/Cargo.toml +1 -1
  13. package/crates/tish_test/Cargo.toml +47 -0
  14. package/crates/tish_test/src/assert.rs +435 -0
  15. package/crates/tish_test/src/config.rs +85 -0
  16. package/crates/tish_test/src/coverage.rs +237 -0
  17. package/crates/tish_test/src/deep_equal.rs +261 -0
  18. package/crates/tish_test/src/discovery.rs +70 -0
  19. package/crates/tish_test/src/expect.rs +977 -0
  20. package/crates/tish_test/src/instrument.rs +457 -0
  21. package/crates/tish_test/src/isolation.rs +14 -0
  22. package/crates/tish_test/src/lib.rs +22 -0
  23. package/crates/tish_test/src/load.rs +152 -0
  24. package/crates/tish_test/src/mocks.rs +270 -0
  25. package/crates/tish_test/src/module.rs +318 -0
  26. package/crates/tish_test/src/registry.rs +235 -0
  27. package/crates/tish_test/src/report.rs +312 -0
  28. package/crates/tish_test/src/runner.rs +797 -0
  29. package/crates/tish_test/src/snapshots.rs +197 -0
  30. package/crates/tish_vm/src/vm.rs +17 -14
  31. package/package.json +1 -1
  32. package/platform/darwin-arm64/tish-fmt +0 -0
  33. package/platform/darwin-x64/tish-fmt +0 -0
  34. package/platform/linux-arm64/tish-fmt +0 -0
  35. package/platform/linux-x64/tish-fmt +0 -0
  36. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -0,0 +1,235 @@
1
+ //! Suite tree registry — collect-then-run (Jest/Bun model).
2
+
3
+ use std::cell::RefCell;
4
+ use std::sync::atomic::{AtomicBool, Ordering};
5
+
6
+ use tishlang_core::Value;
7
+
8
+ static ONLY_MODE: AtomicBool = AtomicBool::new(false);
9
+
10
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
11
+ pub enum TestMode {
12
+ Normal,
13
+ Skip,
14
+ Todo,
15
+ Only,
16
+ Failing,
17
+ }
18
+
19
+ #[derive(Clone)]
20
+ pub struct TestCase {
21
+ pub name: String,
22
+ pub full_name: String,
23
+ pub callback: Value,
24
+ pub mode: TestMode,
25
+ pub timeout_ms: Option<u64>,
26
+ pub tags: Vec<String>,
27
+ pub file: String,
28
+ }
29
+
30
+ #[derive(Clone)]
31
+ pub struct SuiteNode {
32
+ pub name: String,
33
+ pub mode: TestMode,
34
+ pub before_all: Vec<Value>,
35
+ pub after_all: Vec<Value>,
36
+ pub before_each: Vec<Value>,
37
+ pub after_each: Vec<Value>,
38
+ pub tests: Vec<TestCase>,
39
+ pub children: Vec<SuiteNode>,
40
+ }
41
+
42
+ impl SuiteNode {
43
+ pub fn new(name: impl Into<String>) -> Self {
44
+ Self {
45
+ name: name.into(),
46
+ mode: TestMode::Normal,
47
+ before_all: Vec::new(),
48
+ after_all: Vec::new(),
49
+ before_each: Vec::new(),
50
+ after_each: Vec::new(),
51
+ tests: Vec::new(),
52
+ children: Vec::new(),
53
+ }
54
+ }
55
+ }
56
+
57
+ struct CollectState {
58
+ root: SuiteNode,
59
+ /// Stack of indices into nested children from root.
60
+ path: Vec<usize>,
61
+ current_file: String,
62
+ collecting: bool,
63
+ /// Errors thrown by `describe` bodies during collection. A throw here would otherwise
64
+ /// silently drop every test after it, so the runner reports these as failures.
65
+ errors: Vec<String>,
66
+ }
67
+
68
+ impl CollectState {
69
+ fn new() -> Self {
70
+ Self {
71
+ root: SuiteNode::new(""),
72
+ path: Vec::new(),
73
+ current_file: String::new(),
74
+ collecting: false,
75
+ errors: Vec::new(),
76
+ }
77
+ }
78
+
79
+ fn current_mut(&mut self) -> &mut SuiteNode {
80
+ let mut node = &mut self.root;
81
+ for &i in &self.path {
82
+ node = &mut node.children[i];
83
+ }
84
+ node
85
+ }
86
+ }
87
+
88
+ thread_local! {
89
+ static STATE: RefCell<CollectState> = RefCell::new(CollectState::new());
90
+ }
91
+
92
+ pub fn begin_collect(file: &str) {
93
+ STATE.with(|s| {
94
+ let mut st = s.borrow_mut();
95
+ *st = CollectState::new();
96
+ st.current_file = file.to_string();
97
+ st.collecting = true;
98
+ });
99
+ ONLY_MODE.store(false, Ordering::SeqCst);
100
+ }
101
+
102
+ pub fn end_collect() -> SuiteNode {
103
+ STATE.with(|s| {
104
+ let mut st = s.borrow_mut();
105
+ st.collecting = false;
106
+ std::mem::replace(&mut st.root, SuiteNode::new(""))
107
+ })
108
+ }
109
+
110
+ /// Errors thrown by `describe` bodies during the last collection pass.
111
+ pub fn take_collect_errors() -> Vec<String> {
112
+ STATE.with(|s| std::mem::take(&mut s.borrow_mut().errors))
113
+ }
114
+
115
+ pub fn is_collecting() -> bool {
116
+ STATE.with(|s| s.borrow().collecting)
117
+ }
118
+
119
+ fn full_name_for(suite_path: &[String], test_name: &str) -> String {
120
+ let mut parts = suite_path.to_vec();
121
+ parts.push(test_name.to_string());
122
+ parts
123
+ .into_iter()
124
+ .filter(|p| !p.is_empty())
125
+ .collect::<Vec<_>>()
126
+ .join(" > ")
127
+ }
128
+
129
+ /// Names of the suites currently on the collect stack, outermost first.
130
+ fn suite_path_names(st: &CollectState) -> Vec<String> {
131
+ let mut names = Vec::new();
132
+ let mut node = &st.root;
133
+ names.push(node.name.clone());
134
+ for &i in &st.path {
135
+ node = &node.children[i];
136
+ names.push(node.name.clone());
137
+ }
138
+ names
139
+ }
140
+
141
+ pub fn push_describe(name: &str, mode: TestMode, body: &Value) {
142
+ // `describe.only` narrows the run exactly like `test.only` (Jest/Bun): registering one
143
+ // puts the whole file in only-mode so unmarked suites are filtered out.
144
+ if mode == TestMode::Only {
145
+ ONLY_MODE.store(true, Ordering::SeqCst);
146
+ }
147
+ let pushed = STATE.with(|s| {
148
+ let mut st = s.borrow_mut();
149
+ if !st.collecting {
150
+ return false;
151
+ }
152
+ let child = SuiteNode {
153
+ name: name.to_string(),
154
+ mode,
155
+ ..SuiteNode::new(name)
156
+ };
157
+ let idx = {
158
+ let cur = st.current_mut();
159
+ cur.children.push(child);
160
+ cur.children.len() - 1
161
+ };
162
+ st.path.push(idx);
163
+ true
164
+ });
165
+ if !pushed {
166
+ return;
167
+ }
168
+ // Run describe body immediately (Jest model). A throw here would silently drop every
169
+ // remaining registration in the body, so record it — the runner turns it into a failure.
170
+ let _ = tishlang_core::take_pending_throw();
171
+ let _ = tishlang_core::value_call(body, &[]);
172
+ let thrown = tishlang_core::take_pending_throw();
173
+ STATE.with(|s| {
174
+ let mut st = s.borrow_mut();
175
+ st.path.pop();
176
+ if let Some(err) = thrown {
177
+ let full = full_name_for(&suite_path_names(&st), name);
178
+ st.errors.push(format!(
179
+ "{full} (collecting): {}\n Tests registered after this point in the describe body were skipped.",
180
+ err.to_display_string()
181
+ ));
182
+ }
183
+ });
184
+ }
185
+
186
+ pub fn add_test(
187
+ name: &str,
188
+ mode: TestMode,
189
+ body: Value,
190
+ timeout_ms: Option<u64>,
191
+ tags: Vec<String>,
192
+ ) {
193
+ if mode == TestMode::Only {
194
+ ONLY_MODE.store(true, Ordering::SeqCst);
195
+ }
196
+ STATE.with(|s| {
197
+ let mut st = s.borrow_mut();
198
+ if !st.collecting {
199
+ return;
200
+ }
201
+ let path = suite_path_names(&st);
202
+ let file = st.current_file.clone();
203
+ let case = TestCase {
204
+ name: name.to_string(),
205
+ full_name: full_name_for(&path, name),
206
+ callback: body,
207
+ mode,
208
+ timeout_ms,
209
+ tags,
210
+ file,
211
+ };
212
+ st.current_mut().tests.push(case);
213
+ });
214
+ }
215
+
216
+ pub fn add_hook(kind: &str, body: Value) {
217
+ STATE.with(|s| {
218
+ let mut st = s.borrow_mut();
219
+ if !st.collecting {
220
+ return;
221
+ }
222
+ let cur = st.current_mut();
223
+ match kind {
224
+ "beforeAll" | "before" => cur.before_all.push(body),
225
+ "afterAll" | "after" => cur.after_all.push(body),
226
+ "beforeEach" => cur.before_each.push(body),
227
+ "afterEach" => cur.after_each.push(body),
228
+ _ => {}
229
+ }
230
+ });
231
+ }
232
+
233
+ pub fn only_mode() -> bool {
234
+ ONLY_MODE.load(Ordering::SeqCst)
235
+ }
@@ -0,0 +1,312 @@
1
+ //! Console / dots / JUnit reporters for `tish test`.
2
+
3
+ use std::fs;
4
+ use std::io::{self, Write};
5
+ use std::path::Path;
6
+ use std::time::Duration;
7
+
8
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
9
+ pub enum TestStatus {
10
+ Pass,
11
+ Fail,
12
+ Skip,
13
+ Todo,
14
+ /// Excluded by name/tag/`--only` filters — not printed or counted.
15
+ Filtered,
16
+ }
17
+
18
+ #[derive(Clone, Debug)]
19
+ pub struct TestResultRecord {
20
+ pub full_name: String,
21
+ pub file: String,
22
+ pub status: TestStatus,
23
+ pub duration: Duration,
24
+ pub message: Option<String>,
25
+ }
26
+
27
+ #[derive(Clone, Debug, Default)]
28
+ pub struct RunSummary {
29
+ pub passed: usize,
30
+ pub failed: usize,
31
+ pub skipped: usize,
32
+ pub todo: usize,
33
+ pub results: Vec<TestResultRecord>,
34
+ }
35
+
36
+ impl RunSummary {
37
+ pub fn record(&mut self, r: TestResultRecord) {
38
+ match r.status {
39
+ TestStatus::Pass => self.passed += 1,
40
+ TestStatus::Fail => self.failed += 1,
41
+ TestStatus::Skip => self.skipped += 1,
42
+ TestStatus::Todo => self.todo += 1,
43
+ TestStatus::Filtered => {
44
+ // Omitted from counts and reporters.
45
+ return;
46
+ }
47
+ }
48
+ self.results.push(r);
49
+ }
50
+
51
+ pub fn total(&self) -> usize {
52
+ self.passed + self.failed + self.skipped + self.todo
53
+ }
54
+
55
+ pub fn success(&self) -> bool {
56
+ self.failed == 0
57
+ }
58
+ }
59
+
60
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
61
+ pub enum ReporterKind {
62
+ Console,
63
+ Dots,
64
+ }
65
+
66
+ pub struct ConsoleReporter {
67
+ kind: ReporterKind,
68
+ out: io::Stdout,
69
+ /// File header already printed for the current file, so console output groups by file.
70
+ current_file: Option<String>,
71
+ }
72
+
73
+ impl ConsoleReporter {
74
+ pub fn new(kind: ReporterKind) -> Self {
75
+ Self {
76
+ kind,
77
+ out: io::stdout(),
78
+ current_file: None,
79
+ }
80
+ }
81
+
82
+ pub fn on_result(&mut self, r: &TestResultRecord) {
83
+ match self.kind {
84
+ ReporterKind::Dots => {
85
+ let ch = match r.status {
86
+ TestStatus::Pass => '.',
87
+ TestStatus::Fail => 'F',
88
+ TestStatus::Skip => 's',
89
+ TestStatus::Todo => 't',
90
+ TestStatus::Filtered => return,
91
+ };
92
+ let _ = write!(self.out, "{}", ch);
93
+ let _ = self.out.flush();
94
+ }
95
+ ReporterKind::Console => {
96
+ let label = match r.status {
97
+ TestStatus::Pass => "PASS",
98
+ TestStatus::Fail => "FAIL",
99
+ TestStatus::Skip => "SKIP",
100
+ TestStatus::Todo => "TODO",
101
+ TestStatus::Filtered => return,
102
+ };
103
+ // Compare the *displayed* path: a collection error carries the path as given on
104
+ // the command line, while test records carry the canonicalized one.
105
+ let short = short_path(&r.file);
106
+ if !short.is_empty() && self.current_file.as_deref() != Some(short.as_str()) {
107
+ let _ = writeln!(self.out, "\n{short}");
108
+ self.current_file = Some(short);
109
+ }
110
+ let ms = r.duration.as_secs_f64() * 1000.0;
111
+ let _ = writeln!(self.out, " {} {} ({:.1}ms)", label, r.full_name, ms);
112
+ if let Some(msg) = &r.message {
113
+ for line in format_failure(msg).lines() {
114
+ let _ = writeln!(self.out, " {}", line);
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ pub fn finish(&mut self, summary: &RunSummary) {
122
+ if self.kind == ReporterKind::Dots {
123
+ let _ = writeln!(self.out);
124
+ }
125
+ // Every reporter replays failures at the end. `dots` previously printed a bare `F`
126
+ // and no diagnostics at all.
127
+ let failures: Vec<&TestResultRecord> = summary
128
+ .results
129
+ .iter()
130
+ .filter(|r| r.status == TestStatus::Fail)
131
+ .collect();
132
+ if !failures.is_empty() {
133
+ let _ = writeln!(self.out, "\nFailures:\n");
134
+ for (i, r) in failures.iter().enumerate() {
135
+ let _ = writeln!(self.out, " {}) {}", i + 1, r.full_name);
136
+ if !r.file.is_empty() {
137
+ let _ = writeln!(self.out, " {}", short_path(&r.file));
138
+ }
139
+ if let Some(msg) = &r.message {
140
+ for line in format_failure(msg).lines() {
141
+ let _ = writeln!(self.out, " {}", line);
142
+ }
143
+ }
144
+ let _ = writeln!(self.out);
145
+ }
146
+ }
147
+ let _ = writeln!(
148
+ self.out,
149
+ "Tests: {} passed, {} failed, {} skipped, {} todo ({})",
150
+ summary.passed,
151
+ summary.failed,
152
+ summary.skipped,
153
+ summary.todo,
154
+ summary.total()
155
+ );
156
+ }
157
+ }
158
+
159
+ /// Render a thrown value's display string readably.
160
+ ///
161
+ /// `Value::to_display_string` on an `AssertionError` yields the whole object literal
162
+ /// (`{code: …, message: expect(…)…, actual: …}`) with the useful part buried mid-line. Pull the
163
+ /// message out and lead with it.
164
+ pub(crate) fn format_failure(msg: &str) -> String {
165
+ let trimmed = msg.trim();
166
+ if !(trimmed.starts_with('{') && trimmed.ends_with('}')) {
167
+ return msg.to_string();
168
+ }
169
+ let Some(message) = extract_field(trimmed, "message") else {
170
+ return msg.to_string();
171
+ };
172
+ let name = extract_field(trimmed, "name").unwrap_or_else(|| "Error".to_string());
173
+ let mut out = format!("{name}: {message}");
174
+ // Keep the structured comparison fields, one per line, after the message.
175
+ for field in ["operator", "expected", "actual"] {
176
+ if let Some(v) = extract_field(trimmed, field) {
177
+ if !v.contains('\n') {
178
+ out.push_str(&format!("\n {field}: {v}"));
179
+ }
180
+ }
181
+ }
182
+ out
183
+ }
184
+
185
+ /// Render a failure as a GitHub Actions annotation message.
186
+ ///
187
+ /// Annotations are what a reviewer sees inline on the diff, so they get the same readable
188
+ /// formatting as the console. GitHub also terminates the command at the first newline, so a
189
+ /// multi-line message must be percent-escaped or the annotation is truncated to its first line
190
+ /// and the rest leaks into the log as noise.
191
+ pub(crate) fn gh_annotation(msg: &str) -> String {
192
+ format_failure(msg)
193
+ .replace('%', "%25")
194
+ .replace('\r', "%0D")
195
+ .replace('\n', "%0A")
196
+ }
197
+
198
+ /// Read `<key>: <value>` out of a displayed object literal. Values run to the next `, <key>: `
199
+ /// at brace depth 0, which is how `to_display_string` separates them.
200
+ fn extract_field(s: &str, key: &str) -> Option<String> {
201
+ let body = s.strip_prefix('{')?.strip_suffix('}')?;
202
+ let needle = format!("{key}: ");
203
+ let start = if body.starts_with(&needle) {
204
+ 0
205
+ } else {
206
+ body.find(&format!(", {needle}"))? + 2
207
+ };
208
+ let rest = &body[start + needle.len()..];
209
+ let bytes = rest.as_bytes();
210
+ let (mut depth, mut i) = (0i32, 0usize);
211
+ while i < bytes.len() {
212
+ match bytes[i] {
213
+ b'{' | b'[' => depth += 1,
214
+ b'}' | b']' => depth -= 1,
215
+ // A comma ends the value only when the next token looks like `key: ` — message
216
+ // text is full of commas.
217
+ b',' if depth == 0 && starts_with_key(&rest[i + 1..]) => break,
218
+ _ => {}
219
+ }
220
+ i += 1;
221
+ }
222
+ Some(rest[..i].trim().to_string())
223
+ }
224
+
225
+ /// Does `s` begin with `<identifier>: `?
226
+ fn starts_with_key(s: &str) -> bool {
227
+ let s = s.trim_start();
228
+ match s.split_once(": ") {
229
+ Some((k, _)) => {
230
+ !k.is_empty()
231
+ && k.chars()
232
+ .all(|c| c.is_alphanumeric() || c == '_' || c == '$')
233
+ }
234
+ None => false,
235
+ }
236
+ }
237
+
238
+ fn short_path(p: &str) -> String {
239
+ if let Ok(cwd) = std::env::current_dir() {
240
+ if let Ok(rel) = Path::new(p).strip_prefix(&cwd) {
241
+ if let Some(s) = rel.to_str() {
242
+ return s.to_string();
243
+ }
244
+ }
245
+ }
246
+ p.to_string()
247
+ }
248
+
249
+ /// Write a minimal JUnit XML report.
250
+ pub fn write_junit_xml(path: &Path, summary: &RunSummary) -> io::Result<()> {
251
+ if let Some(parent) = path.parent() {
252
+ fs::create_dir_all(parent)?;
253
+ }
254
+ let mut xml = String::new();
255
+ xml.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
256
+ xml.push('\n');
257
+ xml.push_str(&format!(
258
+ r#"<testsuites tests="{}" failures="{}" skipped="{}">"#,
259
+ summary.total(),
260
+ summary.failed,
261
+ summary.skipped + summary.todo
262
+ ));
263
+ xml.push('\n');
264
+ xml.push_str(r#" <testsuite name="tish test">"#);
265
+ xml.push('\n');
266
+ for r in &summary.results {
267
+ let time = r.duration.as_secs_f64();
268
+ xml.push_str(&format!(
269
+ r#" <testcase name="{}" classname="{}" time="{:.6}">"#,
270
+ xml_escape(&r.full_name),
271
+ xml_escape(&r.file),
272
+ time
273
+ ));
274
+ xml.push('\n');
275
+ match r.status {
276
+ TestStatus::Fail => {
277
+ let msg = r.message.as_deref().unwrap_or("failed");
278
+ xml.push_str(&format!(
279
+ " <failure message=\"{}\">{}</failure>\n",
280
+ xml_escape(msg),
281
+ xml_escape(msg)
282
+ ));
283
+ }
284
+ TestStatus::Skip | TestStatus::Todo => {
285
+ xml.push_str(" <skipped/>\n");
286
+ }
287
+ TestStatus::Pass | TestStatus::Filtered => {}
288
+ }
289
+ xml.push_str(" </testcase>\n");
290
+ }
291
+ xml.push_str(" </testsuite>\n</testsuites>\n");
292
+ fs::write(path, xml)
293
+ }
294
+
295
+ /// Escape for XML, dropping control characters. A failure message carrying an ANSI escape or a
296
+ /// NUL would otherwise produce a JUnit file no parser accepts.
297
+ fn xml_escape(s: &str) -> String {
298
+ let mut out = String::with_capacity(s.len());
299
+ for c in s.chars() {
300
+ match c {
301
+ '&' => out.push_str("&amp;"),
302
+ '<' => out.push_str("&lt;"),
303
+ '>' => out.push_str("&gt;"),
304
+ '"' => out.push_str("&quot;"),
305
+ '\'' => out.push_str("&apos;"),
306
+ '\t' | '\n' | '\r' => out.push(c),
307
+ c if (c as u32) < 0x20 || (c as u32) == 0x7f => out.push(' '),
308
+ c => out.push(c),
309
+ }
310
+ }
311
+ out
312
+ }