@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.
- package/Cargo.toml +1 -0
- package/bin/tish-format +0 -0
- package/crates/tish/Cargo.toml +4 -1
- package/crates/tish/src/cli_help.rs +84 -0
- package/crates/tish/src/main.rs +93 -1
- package/crates/tish/tests/test_runner.rs +673 -0
- package/crates/tish_bytecode/src/compiler.rs +25 -4
- package/crates/tish_compile/src/resolve.rs +45 -12
- package/crates/tish_core/src/lib.rs +103 -11
- package/crates/tish_eval/src/eval.rs +16 -0
- package/crates/tish_eval/src/value_convert.rs +10 -0
- package/crates/tish_fmt/Cargo.toml +1 -1
- package/crates/tish_test/Cargo.toml +47 -0
- package/crates/tish_test/src/assert.rs +435 -0
- package/crates/tish_test/src/config.rs +85 -0
- package/crates/tish_test/src/coverage.rs +237 -0
- package/crates/tish_test/src/deep_equal.rs +261 -0
- package/crates/tish_test/src/discovery.rs +70 -0
- package/crates/tish_test/src/expect.rs +977 -0
- package/crates/tish_test/src/instrument.rs +457 -0
- package/crates/tish_test/src/isolation.rs +14 -0
- package/crates/tish_test/src/lib.rs +22 -0
- package/crates/tish_test/src/load.rs +152 -0
- package/crates/tish_test/src/mocks.rs +270 -0
- package/crates/tish_test/src/module.rs +318 -0
- package/crates/tish_test/src/registry.rs +235 -0
- package/crates/tish_test/src/report.rs +312 -0
- package/crates/tish_test/src/runner.rs +797 -0
- package/crates/tish_test/src/snapshots.rs +197 -0
- package/crates/tish_vm/src/vm.rs +17 -14
- package/package.json +1 -1
- package/platform/darwin-arm64/tish-fmt +0 -0
- package/platform/darwin-x64/tish-fmt +0 -0
- package/platform/linux-arm64/tish-fmt +0 -0
- package/platform/linux-x64/tish-fmt +0 -0
- package/platform/win32-x64/tish-fmt.exe +0 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
//! Line coverage collection for `tish test --coverage` (lives entirely in this crate).
|
|
2
|
+
//!
|
|
3
|
+
//! Hits are recorded by AST-injected calls to `__tish_cov_hit__(fileId, line)`, which works
|
|
4
|
+
//! for every execution backend that runs the instrumented program (vm, interp, and later
|
|
5
|
+
//! native/js emit of the same AST).
|
|
6
|
+
|
|
7
|
+
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
|
8
|
+
use std::fs;
|
|
9
|
+
use std::io::{self, Write};
|
|
10
|
+
use std::path::{Path, PathBuf};
|
|
11
|
+
use std::sync::Mutex;
|
|
12
|
+
|
|
13
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
14
|
+
use std::sync::LazyLock;
|
|
15
|
+
|
|
16
|
+
use tishlang_core::Value;
|
|
17
|
+
|
|
18
|
+
/// Read on every instrumented line, so it stays out of the mutex.
|
|
19
|
+
static ENABLED: AtomicBool = AtomicBool::new(false);
|
|
20
|
+
|
|
21
|
+
static STATE: LazyLock<Mutex<CoverageState>> = LazyLock::new(|| Mutex::new(CoverageState::empty()));
|
|
22
|
+
|
|
23
|
+
struct CoverageState {
|
|
24
|
+
files: Vec<String>,
|
|
25
|
+
file_index: HashMap<String, u32>,
|
|
26
|
+
/// Modules already rewritten. A module imported by several test files must only be
|
|
27
|
+
/// instrumented once, or its statements would be wrapped (and counted) twice.
|
|
28
|
+
instrumented: HashSet<String>,
|
|
29
|
+
/// file id → executable lines (from instrumentation sites)
|
|
30
|
+
executable: Vec<BTreeSet<u32>>,
|
|
31
|
+
/// file id → line → hits. Indexed by id so the hot path never hashes or clones a path.
|
|
32
|
+
hits: Vec<BTreeMap<u32, u64>>,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
impl CoverageState {
|
|
36
|
+
fn empty() -> Self {
|
|
37
|
+
Self {
|
|
38
|
+
files: Vec::new(),
|
|
39
|
+
file_index: HashMap::new(),
|
|
40
|
+
instrumented: HashSet::new(),
|
|
41
|
+
executable: Vec::new(),
|
|
42
|
+
hits: Vec::new(),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Enable coverage collection and clear prior data.
|
|
48
|
+
pub fn begin() {
|
|
49
|
+
let mut s = STATE.lock().unwrap();
|
|
50
|
+
*s = CoverageState::empty();
|
|
51
|
+
ENABLED.store(true, Ordering::Relaxed);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
pub fn is_enabled() -> bool {
|
|
55
|
+
ENABLED.load(Ordering::Relaxed)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Intern a source path; returns the numeric id passed to `__tish_cov_hit__`.
|
|
59
|
+
pub fn intern_file(path: &Path) -> u32 {
|
|
60
|
+
let key = normalize_path(path);
|
|
61
|
+
let mut s = STATE.lock().unwrap();
|
|
62
|
+
if let Some(&id) = s.file_index.get(&key) {
|
|
63
|
+
return id;
|
|
64
|
+
}
|
|
65
|
+
let id = s.files.len() as u32;
|
|
66
|
+
s.files.push(key.clone());
|
|
67
|
+
s.file_index.insert(key, id);
|
|
68
|
+
s.executable.push(BTreeSet::new());
|
|
69
|
+
s.hits.push(BTreeMap::new());
|
|
70
|
+
id
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Claim `path` for instrumentation. Returns false if it was already rewritten.
|
|
74
|
+
pub fn mark_instrumented(path: &Path) -> bool {
|
|
75
|
+
let key = normalize_path(path);
|
|
76
|
+
let mut s = STATE.lock().unwrap();
|
|
77
|
+
s.instrumented.insert(key)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
pub fn mark_executable(file_id: u32, line: u32) {
|
|
81
|
+
if line == 0 {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
let mut s = STATE.lock().unwrap();
|
|
85
|
+
if let Some(lines) = s.executable.get_mut(file_id as usize) {
|
|
86
|
+
lines.insert(line);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fn record_hit(file_id: u32, line: u32) {
|
|
91
|
+
if line == 0 || !is_enabled() {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
let mut s = STATE.lock().unwrap();
|
|
95
|
+
if let Some(m) = s.hits.get_mut(file_id as usize) {
|
|
96
|
+
*m.entry(line).or_insert(0) += 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Core native installed as global `__tish_cov_hit__` for VM / via CoreFn for interp.
|
|
101
|
+
pub fn hit_native(args: &[Value]) -> Value {
|
|
102
|
+
let file_id = args.first().and_then(|v| v.as_number()).unwrap_or(0.0) as u32;
|
|
103
|
+
let line = args.get(1).and_then(|v| v.as_number()).unwrap_or(0.0) as u32;
|
|
104
|
+
record_hit(file_id, line);
|
|
105
|
+
Value::Null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
pub fn hit_value() -> Value {
|
|
109
|
+
Value::native(hit_native)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const HIT_GLOBAL: &str = "__tish_cov_hit__";
|
|
113
|
+
|
|
114
|
+
pub fn hit_global_name() -> &'static str {
|
|
115
|
+
HIT_GLOBAL
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// Drop test/spec files from the report (coverage of code under test).
|
|
119
|
+
pub fn retain_non_test_files() {
|
|
120
|
+
let mut s = STATE.lock().unwrap();
|
|
121
|
+
for id in 0..s.files.len() {
|
|
122
|
+
if crate::discovery::is_test_file_name(
|
|
123
|
+
Path::new(&s.files[id])
|
|
124
|
+
.file_name()
|
|
125
|
+
.and_then(|n| n.to_str())
|
|
126
|
+
.unwrap_or(""),
|
|
127
|
+
) {
|
|
128
|
+
s.executable[id].clear();
|
|
129
|
+
s.hits[id].clear();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// `(file, executable lines, hits)` for every file with instrumented lines, sorted by path.
|
|
135
|
+
fn reportable(s: &CoverageState) -> Vec<(&String, &BTreeSet<u32>, &BTreeMap<u32, u64>)> {
|
|
136
|
+
let mut rows: Vec<_> = s
|
|
137
|
+
.files
|
|
138
|
+
.iter()
|
|
139
|
+
.enumerate()
|
|
140
|
+
.filter(|(id, _)| !s.executable[*id].is_empty())
|
|
141
|
+
.map(|(id, f)| (f, &s.executable[id], &s.hits[id]))
|
|
142
|
+
.collect();
|
|
143
|
+
rows.sort_by(|a, b| a.0.cmp(b.0));
|
|
144
|
+
rows
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
pub fn summary() -> (usize, usize) {
|
|
148
|
+
let s = STATE.lock().unwrap();
|
|
149
|
+
let mut total = 0usize;
|
|
150
|
+
let mut covered = 0usize;
|
|
151
|
+
for (_, lines, hits) in reportable(&s) {
|
|
152
|
+
for line in lines {
|
|
153
|
+
total += 1;
|
|
154
|
+
if hits.get(line).copied().unwrap_or(0) > 0 {
|
|
155
|
+
covered += 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
(covered, total)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
pub fn write_lcov(path: &Path) -> io::Result<()> {
|
|
163
|
+
if let Some(parent) = path.parent() {
|
|
164
|
+
fs::create_dir_all(parent)?;
|
|
165
|
+
}
|
|
166
|
+
let s = STATE.lock().unwrap();
|
|
167
|
+
let mut out = fs::File::create(path)?;
|
|
168
|
+
for (file, lines, file_hits) in reportable(&s) {
|
|
169
|
+
writeln!(out, "TN:")?;
|
|
170
|
+
writeln!(out, "SF:{file}")?;
|
|
171
|
+
let mut lf = 0u32;
|
|
172
|
+
let mut lh = 0u32;
|
|
173
|
+
for &line in lines {
|
|
174
|
+
let count = file_hits.get(&line).copied().unwrap_or(0);
|
|
175
|
+
writeln!(out, "DA:{line},{count}")?;
|
|
176
|
+
lf += 1;
|
|
177
|
+
if count > 0 {
|
|
178
|
+
lh += 1;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
writeln!(out, "LF:{lf}")?;
|
|
182
|
+
writeln!(out, "LH:{lh}")?;
|
|
183
|
+
writeln!(out, "end_of_record")?;
|
|
184
|
+
}
|
|
185
|
+
Ok(())
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
pub fn print_summary<W: Write>(mut w: W) -> io::Result<()> {
|
|
189
|
+
let (covered, total) = summary();
|
|
190
|
+
if total == 0 {
|
|
191
|
+
// "100%" of nothing reads as a passing gate. Say what actually happened.
|
|
192
|
+
writeln!(
|
|
193
|
+
w,
|
|
194
|
+
"\nCoverage: no non-test source was executed — nothing to report."
|
|
195
|
+
)?;
|
|
196
|
+
return Ok(());
|
|
197
|
+
}
|
|
198
|
+
let pct = (covered as f64) * 100.0 / (total as f64);
|
|
199
|
+
writeln!(w, "\nCoverage: {covered}/{total} lines ({pct:.1}%)")?;
|
|
200
|
+
let s = STATE.lock().unwrap();
|
|
201
|
+
for (file, lines, hits) in reportable(&s) {
|
|
202
|
+
let t = lines.len();
|
|
203
|
+
let c = lines
|
|
204
|
+
.iter()
|
|
205
|
+
.filter(|l| hits.get(l).copied().unwrap_or(0) > 0)
|
|
206
|
+
.count();
|
|
207
|
+
let p = if t == 0 {
|
|
208
|
+
0.0
|
|
209
|
+
} else {
|
|
210
|
+
(c as f64) * 100.0 / (t as f64)
|
|
211
|
+
};
|
|
212
|
+
writeln!(w, " {p:5.1}% {c}/{t} {}", short_path(file))?;
|
|
213
|
+
}
|
|
214
|
+
Ok(())
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
pub fn default_dir() -> PathBuf {
|
|
218
|
+
PathBuf::from("coverage")
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
fn normalize_path(p: &Path) -> String {
|
|
222
|
+
p.canonicalize()
|
|
223
|
+
.unwrap_or_else(|_| p.to_path_buf())
|
|
224
|
+
.to_string_lossy()
|
|
225
|
+
.into_owned()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
fn short_path(p: &str) -> &str {
|
|
229
|
+
if let Ok(cwd) = std::env::current_dir() {
|
|
230
|
+
if let Ok(rel) = Path::new(p).strip_prefix(&cwd) {
|
|
231
|
+
if let Some(s) = rel.to_str() {
|
|
232
|
+
return s;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
p
|
|
237
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
//! Shared deep strict equality for `expect.toEqual` and `assert.deepStrictEqual`.
|
|
2
|
+
|
|
3
|
+
use std::collections::HashSet;
|
|
4
|
+
|
|
5
|
+
use tishlang_core::{ObjectData, Value, VmRef};
|
|
6
|
+
|
|
7
|
+
fn same_value_zero_num(a: f64, b: f64) -> bool {
|
|
8
|
+
if a.is_nan() && b.is_nan() {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
if a == 0.0 && b == 0.0 {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
a == b
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
fn ptr_key_obj(o: &VmRef<ObjectData>) -> usize {
|
|
18
|
+
o.as_ptr() as usize
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fn ptr_key_arr(a: &VmRef<Vec<Value>>) -> usize {
|
|
22
|
+
a.as_ptr() as usize
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fn type_name_of(v: &Value) -> &'static str {
|
|
26
|
+
match v {
|
|
27
|
+
Value::Null => "null",
|
|
28
|
+
Value::Bool(_) => "Boolean",
|
|
29
|
+
Value::Number(_) => "Number",
|
|
30
|
+
Value::String(_) => "String",
|
|
31
|
+
Value::Symbol(_) => "Symbol",
|
|
32
|
+
Value::Array(_) | Value::NumberArray(_) => "Array",
|
|
33
|
+
Value::Object(_) => "Object",
|
|
34
|
+
Value::Function(_) => "Function",
|
|
35
|
+
#[cfg(feature = "regex")]
|
|
36
|
+
Value::RegExp(_) => "RegExp",
|
|
37
|
+
Value::Promise(_) => "Promise",
|
|
38
|
+
Value::Opaque(_) => "Object",
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Jest-style asymmetric matchers (`expect.any`, `objectContaining`, …) on the expected side.
|
|
43
|
+
fn asymmetric_match(
|
|
44
|
+
actual: &Value,
|
|
45
|
+
expected: &Value,
|
|
46
|
+
seen: &mut HashSet<(usize, usize)>,
|
|
47
|
+
) -> Option<bool> {
|
|
48
|
+
let Value::Object(e) = expected else {
|
|
49
|
+
return None;
|
|
50
|
+
};
|
|
51
|
+
let eb = e.borrow();
|
|
52
|
+
let Some(Value::String(kind)) = eb.strings.get(crate::expect::ASYMMETRIC_KEY) else {
|
|
53
|
+
return None;
|
|
54
|
+
};
|
|
55
|
+
Some(match kind.as_str() {
|
|
56
|
+
"anything" => !matches!(actual, Value::Null),
|
|
57
|
+
"any" => {
|
|
58
|
+
let want = eb
|
|
59
|
+
.strings
|
|
60
|
+
.get("typeName")
|
|
61
|
+
.map(|v| v.to_display_string())
|
|
62
|
+
.unwrap_or_else(|| "Object".into());
|
|
63
|
+
let got = type_name_of(actual);
|
|
64
|
+
want.eq_ignore_ascii_case(got)
|
|
65
|
+
|| (want.eq_ignore_ascii_case("Object") && matches!(actual, Value::Object(_)))
|
|
66
|
+
}
|
|
67
|
+
"objectContaining" => {
|
|
68
|
+
let sample = eb.strings.get("sample").cloned().unwrap_or(Value::Null);
|
|
69
|
+
partial_deep_strict_equal(actual, &sample)
|
|
70
|
+
}
|
|
71
|
+
"arrayContaining" => {
|
|
72
|
+
// The sample needs the same packed-array materialization as `actual`; a numeric
|
|
73
|
+
// literal like `[1, 2]` can arrive as a `NumberArray`.
|
|
74
|
+
let sample = eb
|
|
75
|
+
.strings
|
|
76
|
+
.get("sample")
|
|
77
|
+
.cloned()
|
|
78
|
+
.unwrap_or(Value::Null)
|
|
79
|
+
.coerce_number_array();
|
|
80
|
+
let Value::Array(want) = sample else {
|
|
81
|
+
return Some(false);
|
|
82
|
+
};
|
|
83
|
+
let actual_coerced = actual.clone().coerce_number_array();
|
|
84
|
+
let Value::Array(haystack) = actual_coerced else {
|
|
85
|
+
return Some(false);
|
|
86
|
+
};
|
|
87
|
+
let needles: Vec<Value> = want.borrow().clone();
|
|
88
|
+
let hay: Vec<Value> = haystack.borrow().clone();
|
|
89
|
+
needles.iter().all(|needle| {
|
|
90
|
+
hay.iter()
|
|
91
|
+
.any(|item| deep_strict_equal_inner(item, needle, seen))
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
"stringMatching" => {
|
|
95
|
+
let sample = eb.strings.get("sample").cloned().unwrap_or(Value::Null);
|
|
96
|
+
let Value::String(s) = actual else {
|
|
97
|
+
return Some(false);
|
|
98
|
+
};
|
|
99
|
+
match sample {
|
|
100
|
+
Value::String(pat) => s.contains(pat.as_str()),
|
|
101
|
+
#[cfg(feature = "regex")]
|
|
102
|
+
Value::RegExp(re) => re.borrow_mut().test(s),
|
|
103
|
+
_ => false,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
_ => false,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Deep strict equality with cycle detection.
|
|
111
|
+
pub fn deep_strict_equal(a: &Value, b: &Value) -> bool {
|
|
112
|
+
let mut seen = HashSet::new();
|
|
113
|
+
deep_strict_equal_inner(a, b, &mut seen)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fn deep_strict_equal_inner(a: &Value, b: &Value, seen: &mut HashSet<(usize, usize)>) -> bool {
|
|
117
|
+
// Materialize packed number arrays to generic arrays for structural compare.
|
|
118
|
+
let a = a.clone().coerce_number_array();
|
|
119
|
+
let b = b.clone().coerce_number_array();
|
|
120
|
+
|
|
121
|
+
if let Some(ok) = asymmetric_match(&a, &b, seen) {
|
|
122
|
+
return ok;
|
|
123
|
+
}
|
|
124
|
+
if let Some(ok) = asymmetric_match(&b, &a, seen) {
|
|
125
|
+
return ok;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
match (&a, &b) {
|
|
129
|
+
(Value::Null, Value::Null) => true,
|
|
130
|
+
(Value::Bool(x), Value::Bool(y)) => x == y,
|
|
131
|
+
(Value::Number(x), Value::Number(y)) => same_value_zero_num(*x, *y),
|
|
132
|
+
(Value::String(x), Value::String(y)) => x.as_str() == y.as_str(),
|
|
133
|
+
(Value::Symbol(x), Value::Symbol(y)) => std::sync::Arc::ptr_eq(x, y),
|
|
134
|
+
(Value::Array(x), Value::Array(y)) => {
|
|
135
|
+
if VmRef::ptr_eq(x, y) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
let kx = ptr_key_arr(x);
|
|
139
|
+
let ky = ptr_key_arr(y);
|
|
140
|
+
let pair = (kx.min(ky), kx.max(ky));
|
|
141
|
+
if !seen.insert(pair) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
let xb = x.borrow();
|
|
145
|
+
let yb = y.borrow();
|
|
146
|
+
if xb.len() != yb.len() {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
xb.iter()
|
|
150
|
+
.zip(yb.iter())
|
|
151
|
+
.all(|(u, v)| deep_strict_equal_inner(u, v, seen))
|
|
152
|
+
}
|
|
153
|
+
(Value::Object(x), Value::Object(y)) => {
|
|
154
|
+
if VmRef::ptr_eq(x, y) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
let kx = ptr_key_obj(x);
|
|
158
|
+
let ky = ptr_key_obj(y);
|
|
159
|
+
let pair = (kx.min(ky), kx.max(ky));
|
|
160
|
+
if !seen.insert(pair) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
let xb = x.borrow();
|
|
164
|
+
let yb = y.borrow();
|
|
165
|
+
if xb.strings.len() != yb.strings.len() {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
for (k, v) in xb.strings.iter() {
|
|
169
|
+
match yb.strings.get(k) {
|
|
170
|
+
Some(ov) => {
|
|
171
|
+
if !deep_strict_equal_inner(v, ov, seen) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
None => return false,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
true
|
|
179
|
+
}
|
|
180
|
+
#[cfg(feature = "regex")]
|
|
181
|
+
(Value::RegExp(x), Value::RegExp(y)) => {
|
|
182
|
+
let xb = x.borrow();
|
|
183
|
+
let yb = y.borrow();
|
|
184
|
+
xb.source == yb.source && format!("{}", xb.flags) == format!("{}", yb.flags)
|
|
185
|
+
}
|
|
186
|
+
// Jest compares functions by identity: `expect(fn).toEqual(fn)` passes, two distinct
|
|
187
|
+
// functions never do. Returning false unconditionally failed the identity case.
|
|
188
|
+
(Value::Function(x), Value::Function(y)) => std::sync::Arc::ptr_eq(x, y),
|
|
189
|
+
(Value::Opaque(x), Value::Opaque(y)) => std::sync::Arc::ptr_eq(x, y),
|
|
190
|
+
_ => false,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// Partial deep strict: every key in `expected` exists in `actual` with matching values.
|
|
195
|
+
pub fn partial_deep_strict_equal(actual: &Value, expected: &Value) -> bool {
|
|
196
|
+
match (actual, expected) {
|
|
197
|
+
(Value::Object(a), Value::Object(e)) => {
|
|
198
|
+
let ab = a.borrow();
|
|
199
|
+
let eb = e.borrow();
|
|
200
|
+
for (k, ev) in eb.strings.iter() {
|
|
201
|
+
match ab.strings.get(k) {
|
|
202
|
+
Some(av) => {
|
|
203
|
+
if matches!(ev, Value::Object(_) | Value::Array(_)) {
|
|
204
|
+
if !partial_deep_strict_equal(av, ev) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
} else if !deep_strict_equal(av, ev) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
None => return false,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
true
|
|
215
|
+
}
|
|
216
|
+
(Value::Array(a), Value::Array(e)) => {
|
|
217
|
+
let ab = a.borrow();
|
|
218
|
+
let eb = e.borrow();
|
|
219
|
+
if eb.len() > ab.len() {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
for (ev, av) in eb.iter().zip(ab.iter()) {
|
|
223
|
+
if matches!(ev, Value::Object(_) | Value::Array(_)) {
|
|
224
|
+
if !partial_deep_strict_equal(av, ev) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
} else if !deep_strict_equal(av, ev) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
true
|
|
232
|
+
}
|
|
233
|
+
_ => deep_strict_equal(actual, expected),
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#[cfg(test)]
|
|
238
|
+
mod tests {
|
|
239
|
+
use super::*;
|
|
240
|
+
use tishlang_core::{Arc, ObjectMap};
|
|
241
|
+
|
|
242
|
+
#[test]
|
|
243
|
+
fn primitives() {
|
|
244
|
+
assert!(deep_strict_equal(&Value::Null, &Value::Null));
|
|
245
|
+
assert!(deep_strict_equal(&Value::Number(1.0), &Value::Number(1.0)));
|
|
246
|
+
assert!(deep_strict_equal(
|
|
247
|
+
&Value::Number(f64::NAN),
|
|
248
|
+
&Value::Number(f64::NAN)
|
|
249
|
+
));
|
|
250
|
+
assert!(!deep_strict_equal(&Value::Number(1.0), &Value::Number(2.0)));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#[test]
|
|
254
|
+
fn objects() {
|
|
255
|
+
let mut m1 = ObjectMap::default();
|
|
256
|
+
m1.insert(Arc::from("a"), Value::Number(1.0));
|
|
257
|
+
let mut m2 = ObjectMap::default();
|
|
258
|
+
m2.insert(Arc::from("a"), Value::Number(1.0));
|
|
259
|
+
assert!(deep_strict_equal(&Value::object(m1), &Value::object(m2)));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
//! Discover `*.test.tish` / `*_test.tish` / `*.spec.tish` / `*_spec.tish` files.
|
|
2
|
+
|
|
3
|
+
use std::path::{Path, PathBuf};
|
|
4
|
+
|
|
5
|
+
use walkdir::WalkDir;
|
|
6
|
+
|
|
7
|
+
/// Return true if `name` matches a recognized test file pattern.
|
|
8
|
+
pub fn is_test_file_name(name: &str) -> bool {
|
|
9
|
+
let lower = name.to_ascii_lowercase();
|
|
10
|
+
if !lower.ends_with(".tish") {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
lower.ends_with(".test.tish")
|
|
14
|
+
|| lower.ends_with("_test.tish")
|
|
15
|
+
|| lower.ends_with(".spec.tish")
|
|
16
|
+
|| lower.ends_with("_spec.tish")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/// Walk `roots` for test files. Optional `filters` keep paths whose display string
|
|
20
|
+
/// contains every substring (case-sensitive, Jest-style path filter).
|
|
21
|
+
pub fn discover_tests(roots: &[PathBuf], filters: &[String]) -> Vec<PathBuf> {
|
|
22
|
+
let mut out = Vec::new();
|
|
23
|
+
for root in roots {
|
|
24
|
+
if root.is_file() {
|
|
25
|
+
// An explicit file argument runs even without a test-file suffix, so
|
|
26
|
+
// `tish test path/to/one.tish` works.
|
|
27
|
+
if let Some(name) = root.file_name().and_then(|n| n.to_str()) {
|
|
28
|
+
let is_candidate =
|
|
29
|
+
is_test_file_name(name) || root.extension().map(|e| e == "tish") == Some(true);
|
|
30
|
+
if is_candidate && path_matches(root, filters) {
|
|
31
|
+
out.push(root.clone());
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if !root.exists() {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
for entry in WalkDir::new(root)
|
|
40
|
+
.follow_links(false)
|
|
41
|
+
.into_iter()
|
|
42
|
+
.filter_map(|e| e.ok())
|
|
43
|
+
{
|
|
44
|
+
if !entry.file_type().is_file() {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
let path = entry.path();
|
|
48
|
+
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
|
49
|
+
continue;
|
|
50
|
+
};
|
|
51
|
+
if !is_test_file_name(name) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if path_matches(path, filters) {
|
|
55
|
+
out.push(path.to_path_buf());
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
out.sort();
|
|
60
|
+
out.dedup();
|
|
61
|
+
out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fn path_matches(path: &Path, filters: &[String]) -> bool {
|
|
65
|
+
if filters.is_empty() {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
let s = path.to_string_lossy();
|
|
69
|
+
filters.iter().all(|f| s.contains(f.as_str()))
|
|
70
|
+
}
|