@tishlang/tish 2.2.7 → 2.8.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 +0 -0
- package/crates/js_to_tish/src/transform/expr.rs +5 -1
- package/crates/tish/Cargo.toml +1 -1
- package/crates/tish/src/cli_help.rs +12 -0
- package/crates/tish/src/main.rs +69 -11
- 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 +1386 -42
- package/crates/tish_compile/src/infer.rs +4 -4
- 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/src/codegen.rs +55 -4
- package/crates/tish_compile_js/src/tests_jsx.rs +44 -0
- 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/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 +0 -0
- package/platform/darwin-x64/tish +0 -0
- package/platform/linux-arm64/tish +0 -0
- package/platform/linux-x64/tish +0 -0
- package/platform/win32-x64/tish.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"] }
|
|
@@ -314,6 +314,7 @@ pub fn native_member_definition(
|
|
|
314
314
|
lsp_line: u32,
|
|
315
315
|
lsp_character: u32,
|
|
316
316
|
word: &str,
|
|
317
|
+
open_docs: &HashMap<Url, String>,
|
|
317
318
|
) -> Option<NativeMemberDefinition> {
|
|
318
319
|
let project_root = infer_project_root(file_path, roots)?;
|
|
319
320
|
let from_dir = file_path.parent()?;
|
|
@@ -357,7 +358,8 @@ pub fn native_member_definition(
|
|
|
357
358
|
|
|
358
359
|
if from_s.starts_with("./") || from_s.starts_with("../") {
|
|
359
360
|
if members.len() == 1 {
|
|
360
|
-
let loc =
|
|
361
|
+
let loc =
|
|
362
|
+
resolve_relative_tish(from_dir, from_s, members[0].as_ref(), open_docs)?;
|
|
361
363
|
return Some(NativeMemberDefinition {
|
|
362
364
|
location: loc,
|
|
363
365
|
doc: None,
|
|
@@ -422,6 +424,7 @@ pub fn definition_for_native_receiver_member(
|
|
|
422
424
|
lsp_line: u32,
|
|
423
425
|
lsp_character: u32,
|
|
424
426
|
word: &str,
|
|
427
|
+
open_docs: &HashMap<Url, String>,
|
|
425
428
|
) -> Option<Location> {
|
|
426
429
|
native_member_definition(
|
|
427
430
|
program,
|
|
@@ -432,11 +435,28 @@ pub fn definition_for_native_receiver_member(
|
|
|
432
435
|
lsp_line,
|
|
433
436
|
lsp_character,
|
|
434
437
|
word,
|
|
438
|
+
open_docs,
|
|
435
439
|
)
|
|
436
440
|
.map(|d| d.location)
|
|
437
441
|
}
|
|
438
442
|
|
|
439
|
-
|
|
443
|
+
/// Source text of a target module: the live editor buffer if the file is open with unsaved edits,
|
|
444
|
+
/// otherwise the on-disk contents. Reading the open buffer keeps cross-file goto-definition/hover in
|
|
445
|
+
/// sync with what the user sees — disk would give a stale line/column (or miss a not-yet-saved
|
|
446
|
+
/// export entirely). #148
|
|
447
|
+
fn module_source(can: &Path, u: &Url, open_docs: &HashMap<Url, String>) -> Option<String> {
|
|
448
|
+
match open_docs.get(u) {
|
|
449
|
+
Some(buf) => Some(buf.clone()),
|
|
450
|
+
None => std::fs::read_to_string(can).ok(),
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
fn resolve_relative_tish(
|
|
455
|
+
from_dir: &Path,
|
|
456
|
+
from_s: &str,
|
|
457
|
+
imported: &str,
|
|
458
|
+
open_docs: &HashMap<Url, String>,
|
|
459
|
+
) -> Option<Location> {
|
|
440
460
|
let target = from_dir.join(from_s.trim_start_matches("./"));
|
|
441
461
|
let target = if target.extension().is_none() {
|
|
442
462
|
target.with_extension("tish")
|
|
@@ -445,13 +465,17 @@ fn resolve_relative_tish(from_dir: &Path, from_s: &str, imported: &str) -> Optio
|
|
|
445
465
|
};
|
|
446
466
|
let can = target.canonicalize().ok()?;
|
|
447
467
|
let u = Url::from_file_path(&can).ok()?;
|
|
448
|
-
let src =
|
|
468
|
+
let src = module_source(&can, &u, open_docs)?;
|
|
449
469
|
let prog = tishlang_parser::parse(&src).ok()?;
|
|
450
470
|
crate::find_export(&prog, imported, &u, &src)
|
|
451
471
|
}
|
|
452
472
|
|
|
453
473
|
/// Resolve a relative default import (`import X from "./m"`) to the `export default` in the source.
|
|
454
|
-
fn resolve_relative_tish_default(
|
|
474
|
+
fn resolve_relative_tish_default(
|
|
475
|
+
from_dir: &Path,
|
|
476
|
+
from_s: &str,
|
|
477
|
+
open_docs: &HashMap<Url, String>,
|
|
478
|
+
) -> Option<Location> {
|
|
455
479
|
let target = from_dir.join(from_s.trim_start_matches("./"));
|
|
456
480
|
let target = if target.extension().is_none() {
|
|
457
481
|
target.with_extension("tish")
|
|
@@ -460,7 +484,7 @@ fn resolve_relative_tish_default(from_dir: &Path, from_s: &str) -> Option<Locati
|
|
|
460
484
|
};
|
|
461
485
|
let can = target.canonicalize().ok()?;
|
|
462
486
|
let u = Url::from_file_path(&can).ok()?;
|
|
463
|
-
let src =
|
|
487
|
+
let src = module_source(&can, &u, open_docs)?;
|
|
464
488
|
let prog = tishlang_parser::parse(&src).ok()?;
|
|
465
489
|
crate::find_default_export(&prog, &u, &src)
|
|
466
490
|
}
|
|
@@ -473,6 +497,7 @@ pub fn definition_for_import(
|
|
|
473
497
|
word: &str,
|
|
474
498
|
roots: &[PathBuf],
|
|
475
499
|
cargo_src_cache: &RwLock<HashMap<(PathBuf, String), PathBuf>>,
|
|
500
|
+
open_docs: &HashMap<Url, String>,
|
|
476
501
|
) -> Option<Location> {
|
|
477
502
|
if word.is_empty() {
|
|
478
503
|
return None;
|
|
@@ -506,9 +531,9 @@ pub fn definition_for_import(
|
|
|
506
531
|
// A default import binds the source module's `export default`, which has no name to
|
|
507
532
|
// match; resolve it directly. Named imports resolve by exported name.
|
|
508
533
|
return if is_default {
|
|
509
|
-
resolve_relative_tish_default(from_dir, from_s)
|
|
534
|
+
resolve_relative_tish_default(from_dir, from_s, open_docs)
|
|
510
535
|
} else {
|
|
511
|
-
resolve_relative_tish(from_dir, from_s, imported)
|
|
536
|
+
resolve_relative_tish(from_dir, from_s, imported, open_docs)
|
|
512
537
|
};
|
|
513
538
|
}
|
|
514
539
|
|
|
@@ -563,6 +588,26 @@ mod receiver_member_tests {
|
|
|
563
588
|
assert_eq!(mem, "setTitle");
|
|
564
589
|
}
|
|
565
590
|
|
|
591
|
+
// #148: cross-file goto-def must read the open editor buffer (unsaved edits), not disk.
|
|
592
|
+
#[test]
|
|
593
|
+
fn module_source_prefers_open_buffer_over_disk() {
|
|
594
|
+
use std::collections::HashMap;
|
|
595
|
+
use tower_lsp::lsp_types::Url;
|
|
596
|
+
// A path that does NOT exist on disk: the disk branch would fail, so a non-None result
|
|
597
|
+
// proves the open buffer was used.
|
|
598
|
+
let path = std::path::Path::new("/nonexistent/tish/module.tish");
|
|
599
|
+
let url = Url::parse("file:///nonexistent/tish/module.tish").unwrap();
|
|
600
|
+
let mut docs = HashMap::new();
|
|
601
|
+
docs.insert(url.clone(), "export fn live() {}".to_string());
|
|
602
|
+
assert_eq!(
|
|
603
|
+
super::module_source(path, &url, &docs).as_deref(),
|
|
604
|
+
Some("export fn live() {}"),
|
|
605
|
+
"open buffer must win over (here, absent) disk content"
|
|
606
|
+
);
|
|
607
|
+
// Not open + nonexistent on disk → None (disk read fails), no panic.
|
|
608
|
+
assert_eq!(super::module_source(path, &url, &HashMap::new()), None);
|
|
609
|
+
}
|
|
610
|
+
|
|
566
611
|
#[test]
|
|
567
612
|
fn native_pragma_parse_optional_doc() {
|
|
568
613
|
let src = r"// @tish-source window.innerHeight src/appkit/window_api.rs 289 | Height in points.
|