@tishlang/tish-format 2.2.7 → 2.9.0
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/bin/tish-format +0 -0
- package/crates/js_to_tish/src/transform/expr.rs +5 -1
- package/crates/tish/Cargo.toml +1 -0
- package/crates/tish/src/cli_help.rs +15 -0
- package/crates/tish/src/main.rs +139 -12
- package/crates/tish/tests/integration_test.rs +65 -0
- package/crates/tish_ast/src/ast.rs +6 -2
- package/crates/tish_builtins/src/array.rs +82 -1
- package/crates/tish_builtins/src/globals.rs +50 -16
- package/crates/tish_builtins/src/math.rs +63 -9
- package/crates/tish_builtins/src/number.rs +20 -1
- package/crates/tish_builtins/src/string.rs +68 -4
- package/crates/tish_builtins/src/typedarrays.rs +23 -16
- package/crates/tish_bytecode/src/compiler.rs +94 -28
- package/crates/tish_bytecode/tests/break_continue_bytecode.rs +19 -0
- package/crates/tish_compile/src/check.rs +1 -1
- package/crates/tish_compile/src/codegen.rs +3133 -582
- package/crates/tish_compile/src/infer.rs +1950 -56
- package/crates/tish_compile/src/lib.rs +38 -0
- package/crates/tish_compile/src/resolve.rs +3 -2
- package/crates/tish_compile/src/types.rs +17 -0
- package/crates/tish_compile_js/Cargo.toml +3 -0
- package/crates/tish_compile_js/src/codegen.rs +365 -9
- package/crates/tish_compile_js/src/lib.rs +2 -1
- package/crates/tish_compile_js/src/tests_jsx.rs +229 -1
- package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
- package/crates/tish_core/Cargo.toml +2 -0
- package/crates/tish_core/src/json.rs +135 -34
- package/crates/tish_core/src/lib.rs +23 -1
- package/crates/tish_core/src/value.rs +64 -11
- package/crates/tish_eval/src/eval.rs +144 -197
- package/crates/tish_eval/src/natives.rs +45 -45
- package/crates/tish_eval/src/regex.rs +35 -27
- package/crates/tish_eval/src/value.rs +8 -0
- package/crates/tish_fmt/Cargo.toml +1 -1
- package/crates/tish_fmt/src/lib.rs +45 -1
- package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
- package/crates/tish_lint/src/lib.rs +197 -21
- package/crates/tish_lsp/Cargo.toml +6 -0
- package/crates/tish_lsp/src/import_goto.rs +52 -7
- package/crates/tish_lsp/src/main.rs +856 -140
- package/crates/tish_opt/src/lib.rs +5 -3
- package/crates/tish_parser/src/lib.rs +23 -5
- package/crates/tish_parser/src/parser.rs +15 -26
- package/crates/tish_resolve/src/lib.rs +188 -18
- package/crates/tish_runtime/src/lib.rs +58 -18
- package/crates/tish_ui/src/jsx.rs +2 -2
- package/crates/tish_vm/src/jit.rs +212 -10
- package/crates/tish_vm/src/vm.rs +39 -18
- package/crates/tish_wasm/src/lib.rs +116 -22
- 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
|
@@ -254,46 +254,54 @@ pub fn string_split(input: &str, separator: &Value, limit: Option<usize>) -> Val
|
|
|
254
254
|
return Value::Array(Rc::new(RefCell::new(Vec::new())));
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
|
|
257
|
+
// JS semantics in every branch: split the string fully, then truncate the result to `limit`.
|
|
258
|
+
// (Rust's `splitn` keeps the unsplit remainder in the last slot — that's Python-style maxsplit,
|
|
259
|
+
// not JS — so we must split fully and `truncate`. `truncate(usize::MAX)` is a no-op for the
|
|
260
|
+
// no-limit case.) This mirrors `tish_builtins::string::split_limit` /
|
|
261
|
+
// `tishlang_runtime::string_split_regex` so interp, vm, and the native backends agree.
|
|
262
|
+
let mut result: Vec<Value> = match separator {
|
|
258
263
|
Value::RegExp(re) => {
|
|
259
264
|
let re = re.borrow();
|
|
260
|
-
let mut
|
|
265
|
+
let mut parts = Vec::new();
|
|
261
266
|
let mut last_end = 0;
|
|
262
|
-
|
|
263
267
|
for mat in re.regex.find_iter(input) {
|
|
264
268
|
match mat {
|
|
265
269
|
Ok(m) => {
|
|
266
|
-
|
|
267
|
-
break;
|
|
268
|
-
}
|
|
269
|
-
result.push(Value::String(input[last_end..m.start()].into()));
|
|
270
|
+
parts.push(Value::String(input[last_end..m.start()].into()));
|
|
270
271
|
last_end = m.end();
|
|
271
272
|
}
|
|
272
273
|
Err(_) => break,
|
|
273
274
|
}
|
|
274
275
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
result.push(Value::String(input[last_end..].into()));
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
Value::Array(Rc::new(RefCell::new(result)))
|
|
281
|
-
}
|
|
282
|
-
Value::String(sep) => {
|
|
283
|
-
let parts: Vec<Value> = input
|
|
284
|
-
.splitn(max, sep.as_ref())
|
|
285
|
-
.map(|s| Value::String(s.into()))
|
|
286
|
-
.collect();
|
|
287
|
-
Value::Array(Rc::new(RefCell::new(parts)))
|
|
276
|
+
parts.push(Value::String(input[last_end..].into()));
|
|
277
|
+
parts
|
|
288
278
|
}
|
|
289
|
-
|
|
279
|
+
// JS `split("")` → the string's chars with no surrounding empties (`"".split("")` → `[]`),
|
|
280
|
+
// unlike Rust's `str::split("")`. Mirrors `tish_builtins::string::split_limit`. #247
|
|
281
|
+
Value::String(sep) if sep.is_empty() => input
|
|
282
|
+
.chars()
|
|
283
|
+
.map(|c| Value::String(c.to_string().into()))
|
|
284
|
+
.collect(),
|
|
285
|
+
Value::String(sep) => input
|
|
286
|
+
.split(sep.as_ref())
|
|
287
|
+
.map(|s| Value::String(s.into()))
|
|
288
|
+
.collect(),
|
|
289
|
+
Value::Null => vec![Value::String(input.into())],
|
|
290
290
|
_ => {
|
|
291
291
|
let sep_str = separator.to_string();
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
292
|
+
if sep_str.is_empty() {
|
|
293
|
+
input
|
|
294
|
+
.chars()
|
|
295
|
+
.map(|c| Value::String(c.to_string().into()))
|
|
296
|
+
.collect()
|
|
297
|
+
} else {
|
|
298
|
+
input
|
|
299
|
+
.split(&sep_str)
|
|
300
|
+
.map(|s| Value::String(s.into()))
|
|
301
|
+
.collect()
|
|
302
|
+
}
|
|
297
303
|
}
|
|
298
|
-
}
|
|
304
|
+
};
|
|
305
|
+
result.truncate(max);
|
|
306
|
+
Value::Array(Rc::new(RefCell::new(result)))
|
|
299
307
|
}
|
|
@@ -197,6 +197,10 @@ impl std::fmt::Debug for Value {
|
|
|
197
197
|
impl std::fmt::Display for Value {
|
|
198
198
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
199
199
|
match self {
|
|
200
|
+
// Inspect form (`console.log`): keeps the sign of negative zero, unlike the ECMAScript
|
|
201
|
+
// ToString used by `to_js_string`. Matches Node's console output (`console.log(-0)` →
|
|
202
|
+
// `-0`). `to_js_string` has an explicit `Number` arm so ToString still drops it. (#247)
|
|
203
|
+
Value::Number(n) if *n == 0.0 && n.is_sign_negative() => write!(f, "-0"),
|
|
200
204
|
// Match JS `Number.prototype.toString` (exponential past digit 21 / before −6),
|
|
201
205
|
// shared with the VM/native path via `tishlang_core`.
|
|
202
206
|
Value::Number(n) => write!(f, "{}", tishlang_core::js_number_to_string(*n)),
|
|
@@ -279,6 +283,10 @@ impl Value {
|
|
|
279
283
|
.collect::<Vec<_>>()
|
|
280
284
|
.join(","),
|
|
281
285
|
Value::Object(_) => "[object Object]".to_string(),
|
|
286
|
+
// ECMAScript ToString of a number drops `-0`'s sign (`String(-0) === "0"`), distinct
|
|
287
|
+
// from the inspect `Display` above which keeps it. Explicit arm so the `_` fallback to
|
|
288
|
+
// `to_string()` (inspect) is not used for numbers. (#247)
|
|
289
|
+
Value::Number(n) => tishlang_core::js_number_to_string(*n),
|
|
282
290
|
_ => self.to_string(),
|
|
283
291
|
}
|
|
284
292
|
}
|
|
@@ -248,7 +248,7 @@ impl Printer {
|
|
|
248
248
|
|
|
249
249
|
fn object_prop(&mut self, pr: &ObjectProp) {
|
|
250
250
|
match pr {
|
|
251
|
-
ObjectProp::KeyValue(k, v) => {
|
|
251
|
+
ObjectProp::KeyValue(k, v, _) => {
|
|
252
252
|
self.buf.push_str(k.as_ref());
|
|
253
253
|
self.buf.push_str(": ");
|
|
254
254
|
self.expr(v);
|
|
@@ -2235,4 +2235,48 @@ let x = add(1, 2)
|
|
|
2235
2235
|
tishlang_parser::parse(&out).unwrap();
|
|
2236
2236
|
assert_eq!(format_source(&out).unwrap(), out, "not idempotent:\n{out}");
|
|
2237
2237
|
}
|
|
2238
|
+
|
|
2239
|
+
/// Broad idempotence guard (#163): the existing tests targeted specific constructs (JSX, new-
|
|
2240
|
+
/// expr) that *had* regressed, leaving structural blind spots. This sweeps a diverse corpus and
|
|
2241
|
+
/// asserts, for every snippet, that (a) the formatted output re-parses and (b) re-formatting is
|
|
2242
|
+
/// a fixed point — so any future construct that formats non-idempotently is caught here.
|
|
2243
|
+
#[test]
|
|
2244
|
+
fn format_is_idempotent_over_a_corpus() {
|
|
2245
|
+
let corpus = [
|
|
2246
|
+
"let a = { x: 1, y: { z: [1, 2, 3] } }\n",
|
|
2247
|
+
"let f = (a, b) => a + b\n",
|
|
2248
|
+
"let g = (x) => { return x * 2 }\n",
|
|
2249
|
+
"const e = <div class=\"x\">{a}<span>b</span></div>\n",
|
|
2250
|
+
"let frag = <><a>1</a><b>2</b></>\n",
|
|
2251
|
+
"let n = new Foo(1).bar().baz\n",
|
|
2252
|
+
"let n2 = new (getCtor())(arg)\n",
|
|
2253
|
+
"let t = `a${b}c${d}e`\n",
|
|
2254
|
+
"let lit = \"has a literal $ and a ${notInterp}\"\n",
|
|
2255
|
+
"if (x) { return 1 } else if (y) { return 2 } else { return 3 }\n",
|
|
2256
|
+
"for (let i = 0; i < n; i = i + 1) { f(i) }\n",
|
|
2257
|
+
"for (const k of items) { use(k) }\n",
|
|
2258
|
+
"while (cond) { step() }\n",
|
|
2259
|
+
"do { once() } while (again)\n",
|
|
2260
|
+
"switch (x) { case 1: f(); break; default: g() }\n",
|
|
2261
|
+
"let u: number | string = 1\n",
|
|
2262
|
+
"type T = { a: number, b: string[] }\n",
|
|
2263
|
+
"export fn h(x: T): T { return x }\n",
|
|
2264
|
+
"let v = a ? b : c\n",
|
|
2265
|
+
"let w = a ?? b\n",
|
|
2266
|
+
"let arr = [{ k: 1 }, { k: 2 }, ...rest]\n",
|
|
2267
|
+
"let obj = { ...base, x: 1, y: 2 }\n",
|
|
2268
|
+
"let chain = a?.b?.c\n",
|
|
2269
|
+
"let big = 1e400\n",
|
|
2270
|
+
"try { risky() } catch (e) { handle(e) } finally { cleanup() }\n",
|
|
2271
|
+
"async fn af() { return await thing() }\n",
|
|
2272
|
+
"import { a, b as c } from \"./m\"\nexport default a\n",
|
|
2273
|
+
];
|
|
2274
|
+
for src in corpus {
|
|
2275
|
+
let once = format_source(src).expect("format");
|
|
2276
|
+
tishlang_parser::parse(&once)
|
|
2277
|
+
.unwrap_or_else(|e| panic!("formatted output must re-parse for {src:?}: {e}\n{once}"));
|
|
2278
|
+
let twice = format_source(&once).expect("re-format");
|
|
2279
|
+
assert_eq!(once, twice, "non-idempotent for {src:?}:\n{once:?}\n ->\n{twice:?}");
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2238
2282
|
}
|
|
@@ -20,6 +20,11 @@ struct Cli {
|
|
|
20
20
|
#[arg(long = "format", value_enum, default_value_t = OutputFormat::Text)]
|
|
21
21
|
output_format: OutputFormat,
|
|
22
22
|
|
|
23
|
+
/// Exit non-zero if any WARNING is found (not just errors), so CI can gate on lint findings.
|
|
24
|
+
/// Without it, lint warnings print but the process exits 0 (only parse errors fail). (#154)
|
|
25
|
+
#[arg(long = "deny-warnings")]
|
|
26
|
+
deny_warnings: bool,
|
|
27
|
+
|
|
23
28
|
#[arg(required = true)]
|
|
24
29
|
paths: Vec<String>,
|
|
25
30
|
}
|
|
@@ -36,7 +41,7 @@ struct Issue {
|
|
|
36
41
|
|
|
37
42
|
fn main() {
|
|
38
43
|
let cli = Cli::parse();
|
|
39
|
-
if let Err(e) = run(&cli.paths, cli.output_format) {
|
|
44
|
+
if let Err(e) = run(&cli.paths, cli.output_format, cli.deny_warnings) {
|
|
40
45
|
eprintln!("{}", e);
|
|
41
46
|
std::process::exit(1);
|
|
42
47
|
}
|
|
@@ -67,7 +72,7 @@ fn collect_files(paths: &[String]) -> Result<Vec<PathBuf>, String> {
|
|
|
67
72
|
Ok(files)
|
|
68
73
|
}
|
|
69
74
|
|
|
70
|
-
fn run(paths: &[String], format: OutputFormat) -> Result<(), String> {
|
|
75
|
+
fn run(paths: &[String], format: OutputFormat, deny_warnings: bool) -> Result<(), String> {
|
|
71
76
|
let files = collect_files(paths)?;
|
|
72
77
|
let mut issues: Vec<Issue> = Vec::new();
|
|
73
78
|
for f in files {
|
|
@@ -103,6 +108,13 @@ fn run(paths: &[String], format: OutputFormat) -> Result<(), String> {
|
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
let error_count = issues.iter().filter(|i| i.level == "error").count();
|
|
111
|
+
let warning_count = issues.iter().filter(|i| i.level == "warning").count();
|
|
112
|
+
// Always fail on errors (parse errors); with --deny-warnings, lint warnings fail too.
|
|
113
|
+
let fail_count = if deny_warnings {
|
|
114
|
+
error_count + warning_count
|
|
115
|
+
} else {
|
|
116
|
+
error_count
|
|
117
|
+
};
|
|
106
118
|
|
|
107
119
|
match format {
|
|
108
120
|
OutputFormat::Text => {
|
|
@@ -117,18 +129,15 @@ fn run(paths: &[String], format: OutputFormat) -> Result<(), String> {
|
|
|
117
129
|
i.message
|
|
118
130
|
);
|
|
119
131
|
}
|
|
120
|
-
if error_count > 0 {
|
|
121
|
-
return Err(format!("{} issue(s)", error_count));
|
|
122
|
-
}
|
|
123
132
|
}
|
|
124
133
|
OutputFormat::Sarif => {
|
|
125
134
|
print_sarif(&issues)?;
|
|
126
|
-
if error_count > 0 {
|
|
127
|
-
return Err(format!("{} issue(s)", error_count));
|
|
128
|
-
}
|
|
129
135
|
}
|
|
130
136
|
}
|
|
131
137
|
|
|
138
|
+
if fail_count > 0 {
|
|
139
|
+
return Err(format!("{} issue(s)", fail_count));
|
|
140
|
+
}
|
|
132
141
|
Ok(())
|
|
133
142
|
}
|
|
134
143
|
|
|
@@ -24,12 +24,47 @@ pub struct LintDiagnostic {
|
|
|
24
24
|
/// Run all default rules on parsed program.
|
|
25
25
|
pub fn lint_program(program: &Program) -> Vec<LintDiagnostic> {
|
|
26
26
|
let mut out = Vec::new();
|
|
27
|
+
check_unreachable(&program.statements, &mut out);
|
|
27
28
|
for s in &program.statements {
|
|
28
29
|
lint_stmt(s, &mut out);
|
|
29
30
|
}
|
|
30
31
|
out
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/// `tish-unreachable-code` (no-unreachable): in a statement sequence, the first statement after an
|
|
35
|
+
/// unconditional `return`/`throw`/`break`/`continue` sibling can never run. Flags only that first
|
|
36
|
+
/// statement (not the whole tail — less noise) and skips hoisted `fn` declarations, which are valid
|
|
37
|
+
/// after a terminator. Only a DIRECT-sibling terminator triggers it, so a conditional
|
|
38
|
+
/// `if (c) return` followed by more statements is correctly NOT flagged.
|
|
39
|
+
fn check_unreachable(stmts: &[Statement], out: &mut Vec<LintDiagnostic>) {
|
|
40
|
+
let mut terminated = false;
|
|
41
|
+
for st in stmts {
|
|
42
|
+
if terminated {
|
|
43
|
+
if matches!(st, Statement::FunDecl { .. }) {
|
|
44
|
+
continue; // function declarations hoist — reachable regardless of position
|
|
45
|
+
}
|
|
46
|
+
let (line, col) = stmt_span(st);
|
|
47
|
+
out.push(LintDiagnostic {
|
|
48
|
+
code: "tish-unreachable-code",
|
|
49
|
+
message: "Unreachable code after return/throw/break/continue.".into(),
|
|
50
|
+
line,
|
|
51
|
+
col,
|
|
52
|
+
severity: Severity::Warning,
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if matches!(
|
|
57
|
+
st,
|
|
58
|
+
Statement::Return { .. }
|
|
59
|
+
| Statement::Throw { .. }
|
|
60
|
+
| Statement::Break { .. }
|
|
61
|
+
| Statement::Continue { .. }
|
|
62
|
+
) {
|
|
63
|
+
terminated = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
33
68
|
/// Lint source: parse then lint. Parse errors are not reported here.
|
|
34
69
|
pub fn lint_source(source: &str) -> Result<Vec<LintDiagnostic>, String> {
|
|
35
70
|
let program = tishlang_parser::parse(source)?;
|
|
@@ -63,51 +98,86 @@ fn lint_stmt(s: &Statement, out: &mut Vec<LintDiagnostic>) {
|
|
|
63
98
|
lint_stmt(fb, out);
|
|
64
99
|
}
|
|
65
100
|
}
|
|
66
|
-
|
|
101
|
+
// Block and the transparent comma-declarator group (#141: Multi was previously skipped).
|
|
102
|
+
Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
|
|
103
|
+
check_unreachable(statements, out);
|
|
67
104
|
for st in statements {
|
|
68
105
|
lint_stmt(st, out);
|
|
69
106
|
}
|
|
70
107
|
}
|
|
108
|
+
// #140: control-flow CONDITION expressions are linted too, not just bodies.
|
|
71
109
|
Statement::If {
|
|
110
|
+
cond,
|
|
72
111
|
then_branch,
|
|
73
112
|
else_branch,
|
|
74
113
|
..
|
|
75
114
|
} => {
|
|
115
|
+
lint_expr(cond, out);
|
|
76
116
|
lint_stmt(then_branch, out);
|
|
77
117
|
if let Some(e) = else_branch {
|
|
78
118
|
lint_stmt(e, out);
|
|
79
119
|
}
|
|
80
120
|
}
|
|
81
|
-
Statement::While {
|
|
82
|
-
|
|
121
|
+
Statement::While { cond, body, .. } => {
|
|
122
|
+
lint_expr(cond, out);
|
|
123
|
+
lint_stmt(body, out);
|
|
124
|
+
}
|
|
125
|
+
Statement::ForOf { iterable, body, .. } => {
|
|
126
|
+
lint_expr(iterable, out);
|
|
127
|
+
lint_stmt(body, out);
|
|
128
|
+
}
|
|
129
|
+
Statement::For {
|
|
130
|
+
init,
|
|
131
|
+
cond,
|
|
132
|
+
update,
|
|
133
|
+
body,
|
|
134
|
+
..
|
|
135
|
+
} => {
|
|
83
136
|
if let Some(i) = init {
|
|
84
137
|
lint_stmt(i, out);
|
|
85
138
|
}
|
|
139
|
+
if let Some(c) = cond {
|
|
140
|
+
lint_expr(c, out);
|
|
141
|
+
}
|
|
142
|
+
if let Some(u) = update {
|
|
143
|
+
lint_expr(u, out);
|
|
144
|
+
}
|
|
86
145
|
lint_stmt(body, out);
|
|
87
146
|
}
|
|
88
147
|
Statement::FunDecl { body, .. } => lint_stmt(body, out),
|
|
89
148
|
Statement::Switch {
|
|
149
|
+
expr,
|
|
90
150
|
cases,
|
|
91
151
|
default_body,
|
|
92
152
|
..
|
|
93
153
|
} => {
|
|
94
|
-
|
|
154
|
+
lint_expr(expr, out);
|
|
155
|
+
for (case_expr, stmts) in cases {
|
|
156
|
+
if let Some(ce) = case_expr {
|
|
157
|
+
lint_expr(ce, out);
|
|
158
|
+
}
|
|
159
|
+
check_unreachable(stmts, out);
|
|
95
160
|
for st in stmts {
|
|
96
161
|
lint_stmt(st, out);
|
|
97
162
|
}
|
|
98
163
|
}
|
|
99
164
|
if let Some(def) = default_body {
|
|
165
|
+
check_unreachable(def, out);
|
|
100
166
|
for st in def {
|
|
101
167
|
lint_stmt(st, out);
|
|
102
168
|
}
|
|
103
169
|
}
|
|
104
170
|
}
|
|
105
|
-
Statement::DoWhile { body, .. } =>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
lint_stmt(inner, out);
|
|
109
|
-
}
|
|
171
|
+
Statement::DoWhile { body, cond, .. } => {
|
|
172
|
+
lint_stmt(body, out);
|
|
173
|
+
lint_expr(cond, out);
|
|
110
174
|
}
|
|
175
|
+
Statement::Export { declaration, .. } => match declaration.as_ref() {
|
|
176
|
+
tishlang_ast::ExportDeclaration::Named(inner) => lint_stmt(inner, out),
|
|
177
|
+
// Walk `export default <expr>` too — its subtree was previously never linted, so e.g.
|
|
178
|
+
// `export default { a: 1, a: 2 }` produced no tish-duplicate-key warning (#151).
|
|
179
|
+
tishlang_ast::ExportDeclaration::Default(expr) => lint_expr(expr, out),
|
|
180
|
+
},
|
|
111
181
|
Statement::ExprStmt { expr, .. } => lint_expr(expr, out),
|
|
112
182
|
Statement::VarDecl { init: Some(e), .. } => lint_expr(e, out),
|
|
113
183
|
Statement::VarDeclDestructure { init, .. } => lint_expr(init, out),
|
|
@@ -126,29 +196,26 @@ fn is_empty_block_or_stmt(s: &Statement) -> bool {
|
|
|
126
196
|
}
|
|
127
197
|
|
|
128
198
|
fn stmt_span(s: &Statement) -> (u32, u32) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
_ => tishlang_ast::Span {
|
|
133
|
-
start: (1, 1),
|
|
134
|
-
end: (1, 1),
|
|
135
|
-
},
|
|
136
|
-
};
|
|
199
|
+
// Use the AST's uniform accessor so every statement kind reports its real position (the old
|
|
200
|
+
// local match only handled Block/Try and defaulted everything else to (1,1)).
|
|
201
|
+
let sp = s.span();
|
|
137
202
|
(sp.start.0 as u32, sp.start.1 as u32)
|
|
138
203
|
}
|
|
139
204
|
|
|
140
205
|
fn lint_expr(e: &Expr, out: &mut Vec<LintDiagnostic>) {
|
|
141
206
|
match e {
|
|
142
|
-
Expr::Object { props,
|
|
207
|
+
Expr::Object { props, .. } => {
|
|
143
208
|
let mut seen: HashSet<&str> = HashSet::new();
|
|
144
209
|
for p in props {
|
|
145
|
-
if let ObjectProp::KeyValue(k, v) = p {
|
|
210
|
+
if let ObjectProp::KeyValue(k, v, kspan) = p {
|
|
146
211
|
if !seen.insert(k.as_ref()) {
|
|
212
|
+
// Point at the duplicated KEY, not the enclosing `{` — so distinct dupes get
|
|
213
|
+
// distinct positions (#143).
|
|
147
214
|
out.push(LintDiagnostic {
|
|
148
215
|
code: "tish-duplicate-key",
|
|
149
216
|
message: format!("Duplicate object key `{}`", k),
|
|
150
|
-
line:
|
|
151
|
-
col:
|
|
217
|
+
line: kspan.start.0 as u32,
|
|
218
|
+
col: kspan.start.1 as u32,
|
|
152
219
|
severity: Severity::Warning,
|
|
153
220
|
});
|
|
154
221
|
}
|
|
@@ -238,6 +305,8 @@ fn lint_expr(e: &Expr, out: &mut Vec<LintDiagnostic>) {
|
|
|
238
305
|
}
|
|
239
306
|
Expr::Await { operand, .. } => lint_expr(operand, out),
|
|
240
307
|
Expr::TypeOf { operand, .. } => lint_expr(operand, out),
|
|
308
|
+
// #142: descend into a delete target (`delete obj[expr]` / `delete (expr).prop`).
|
|
309
|
+
Expr::Delete { target, .. } => lint_expr(target, out),
|
|
241
310
|
Expr::JsxElement {
|
|
242
311
|
props, children, ..
|
|
243
312
|
} => {
|
|
@@ -279,3 +348,110 @@ pub const RULES: &[(&str, &str)] = &[
|
|
|
279
348
|
"Warns when an object literal repeats the same key.",
|
|
280
349
|
),
|
|
281
350
|
];
|
|
351
|
+
|
|
352
|
+
#[cfg(test)]
|
|
353
|
+
mod tests {
|
|
354
|
+
use super::*;
|
|
355
|
+
|
|
356
|
+
/// Count `tish-duplicate-key` diagnostics the linter emits for `src` — a probe for whether the
|
|
357
|
+
/// linter actually descends into a given position (the dup-key rule fires on any object literal
|
|
358
|
+
/// the walk reaches).
|
|
359
|
+
fn dup_keys(src: &str) -> usize {
|
|
360
|
+
lint_source(src)
|
|
361
|
+
.expect("parse")
|
|
362
|
+
.iter()
|
|
363
|
+
.filter(|d| d.code == "tish-duplicate-key")
|
|
364
|
+
.count()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#[test]
|
|
368
|
+
fn baseline_dup_key_in_plain_var_is_linted() {
|
|
369
|
+
assert_eq!(dup_keys("let x = { a: 1, a: 2 }\n"), 1);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// #141: comma-separated declarators lower to Statement::Multi, which the linter skipped.
|
|
373
|
+
#[test]
|
|
374
|
+
fn lints_inside_comma_declarators() {
|
|
375
|
+
assert!(
|
|
376
|
+
dup_keys("let x = { a: 1, a: 2 }, y = 3\n") >= 1,
|
|
377
|
+
"dup key in a comma-declarator (Statement::Multi) must be linted"
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// #140: control-flow CONDITION expressions were never linted (only bodies were walked).
|
|
382
|
+
#[test]
|
|
383
|
+
fn lints_inside_control_flow_conditions() {
|
|
384
|
+
assert!(dup_keys("if ({ a: 1, a: 2 }) {}\n") >= 1, "if condition");
|
|
385
|
+
assert!(dup_keys("while ({ a: 1, a: 2 }) { break }\n") >= 1, "while condition");
|
|
386
|
+
assert!(
|
|
387
|
+
dup_keys("for (let i = 0; ({ a: 1, a: 2 }); i = i + 1) { break }\n") >= 1,
|
|
388
|
+
"for condition"
|
|
389
|
+
);
|
|
390
|
+
assert!(dup_keys("do { break } while ({ a: 1, a: 2 })\n") >= 1, "do-while condition");
|
|
391
|
+
assert!(
|
|
392
|
+
dup_keys("switch ({ a: 1, a: 2 }) { case 1: break }\n") >= 1,
|
|
393
|
+
"switch discriminant"
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// #142: the linter never descended into a delete target.
|
|
398
|
+
#[test]
|
|
399
|
+
fn lints_inside_delete_target() {
|
|
400
|
+
assert!(dup_keys("delete ({ a: 1, a: 2 }).a\n") >= 1, "delete target");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// #151: the `export default <expr>` subtree was never walked (only `export <named>` was).
|
|
404
|
+
#[test]
|
|
405
|
+
fn lints_inside_export_default() {
|
|
406
|
+
assert!(
|
|
407
|
+
dup_keys("export default { a: 1, a: 2 }\n") >= 1,
|
|
408
|
+
"dup key inside `export default` must be linted"
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// #152: no-unreachable.
|
|
413
|
+
fn unreachable(src: &str) -> usize {
|
|
414
|
+
lint_source(src)
|
|
415
|
+
.expect("parse")
|
|
416
|
+
.iter()
|
|
417
|
+
.filter(|d| d.code == "tish-unreachable-code")
|
|
418
|
+
.count()
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
#[test]
|
|
422
|
+
fn flags_code_after_terminator() {
|
|
423
|
+
assert_eq!(unreachable("fn f() {\n return 1\n let x = 2\n}\n"), 1, "after return");
|
|
424
|
+
assert_eq!(unreachable("fn g() {\n throw 1\n g()\n}\n"), 1, "after throw");
|
|
425
|
+
assert_eq!(
|
|
426
|
+
unreachable("fn h() {\n while (true) {\n break\n h()\n }\n}\n"),
|
|
427
|
+
1,
|
|
428
|
+
"after break in loop body"
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
#[test]
|
|
433
|
+
fn does_not_flag_reachable_or_hoisted() {
|
|
434
|
+
// Conditional return is NOT a direct-sibling terminator → following code is reachable.
|
|
435
|
+
assert_eq!(unreachable("fn f(c) {\n if (c) { return 1 }\n let x = 2\n}\n"), 0, "conditional");
|
|
436
|
+
// Function declarations hoist — valid after a return.
|
|
437
|
+
assert_eq!(unreachable("fn g() {\n return 1\n fn helper() { return 2 }\n}\n"), 0, "hoisted fn");
|
|
438
|
+
// Switch fall-through (no terminator) is intentional.
|
|
439
|
+
assert_eq!(unreachable("switch (x) {\n case 1:\n a()\n case 2:\n b()\n}\n"), 0, "fallthrough");
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
#[cfg(test)]
|
|
444
|
+
mod dupkey_position_tests {
|
|
445
|
+
use super::*;
|
|
446
|
+
#[test]
|
|
447
|
+
fn dup_key_points_at_the_duplicated_key() {
|
|
448
|
+
// `let x = { a: 1, a: 2 }` — the duplicate `a` is at 1-indexed col 17; the object `{` is col 9.
|
|
449
|
+
let d: Vec<_> = lint_source("let x = { a: 1, a: 2 }\n")
|
|
450
|
+
.expect("parse")
|
|
451
|
+
.into_iter()
|
|
452
|
+
.filter(|d| d.code == "tish-duplicate-key")
|
|
453
|
+
.collect();
|
|
454
|
+
assert_eq!(d.len(), 1, "exactly one dup-key");
|
|
455
|
+
assert_eq!((d[0].line, d[0].col), (1, 17), "#143: must point at the duplicated key, not the `{{`");
|
|
456
|
+
}
|
|
457
|
+
}
|
|
@@ -23,3 +23,9 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "tim
|
|
|
23
23
|
serde_json = "1"
|
|
24
24
|
regex = "1"
|
|
25
25
|
walkdir = "2"
|
|
26
|
+
|
|
27
|
+
[dev-dependencies]
|
|
28
|
+
# Drive `LspService` over JSON-RPC in tests (#163). Must match the `tower` major that tower-lsp 0.20
|
|
29
|
+
# implements `Service` against (0.4), or the trait bound on `.call()` won't be satisfied. `util`
|
|
30
|
+
# brings in `ServiceExt::ready`.
|
|
31
|
+
tower = { version = "0.4", features = ["util"] }
|