plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
1ac13cd
— Peter John
2026-11-02T15:34:17+05:30
Report parse errors at the offending token with a hint for `()`, in the loader and LSP
- plum-core/src/lib.rs +3 -1
- plum-core/src/loader.rs +220 -13
- plum-core/tests/loader_test.rs +52 -0
- plum-lsp/src/main.rs +41 -40
plum-core/src/lib.rs
CHANGED
|
@@ -14,5 +14,7 @@ pub mod unions;
|
|
|
14
14
|
pub use formatter::{
|
|
15
15
|
formatSource, formatSourceWithOpts, inlineSingleStatementCaseBodies, FormatterError,
|
|
16
16
|
};
|
|
17
|
+
pub use loader::{
|
|
17
|
-
|
|
18
|
+
loadAndMerge, loadAndMergeWithOverrides, syntaxErrors, SyntaxError, SyntaxErrorKind,
|
|
19
|
+
};
|
|
18
20
|
pub use parser::AstParser;
|
plum-core/src/loader.rs
CHANGED
|
@@ -214,14 +214,13 @@ fn parseFileWithOverrides(
|
|
|
214
214
|
// (including `ERROR`). Left unchecked, that meant a single bad token
|
|
215
215
|
// anywhere in the file silently discarded every top-level item after
|
|
216
216
|
// it — no parse error, just fewer items than the file actually has.
|
|
217
|
-
if let Some(err) =
|
|
217
|
+
if let Some(err) = syntaxErrors(root, &text).into_iter().next() {
|
|
218
|
-
let start = err.start_position();
|
|
219
218
|
return Err(format!(
|
|
220
|
-
"parse error in '{}' at line {}, column {}:
|
|
219
|
+
"parse error in '{}' at line {}, column {}: {}",
|
|
221
220
|
path.display(),
|
|
222
|
-
start.row + 1,
|
|
221
|
+
err.start.row + 1,
|
|
223
|
-
start.column + 1,
|
|
222
|
+
err.start.column + 1,
|
|
224
|
-
err.
|
|
223
|
+
err.message(),
|
|
225
224
|
));
|
|
226
225
|
}
|
|
227
226
|
let ap = AstParser::new(&text);
|
|
@@ -233,23 +232,231 @@ fn parseFileWithOverrides(
|
|
|
233
232
|
Ok(source)
|
|
234
233
|
}
|
|
235
234
|
|
|
235
|
+
/// A syntax error pinned to the token tree-sitter could not fit.
|
|
236
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
237
|
+
pub struct SyntaxError {
|
|
236
|
-
///
|
|
238
|
+
/// Start of the offending token (for end of file, the end of the last token).
|
|
239
|
+
pub start: tree_sitter::Point,
|
|
240
|
+
/// End of the offending token; equal to `start` at end of file.
|
|
241
|
+
pub end: tree_sitter::Point,
|
|
242
|
+
pub kind: SyntaxErrorKind,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
246
|
+
pub enum SyntaxErrorKind {
|
|
237
|
-
///
|
|
247
|
+
/// A token that fits nowhere, as it appears in the source (single line, at most 40 chars).
|
|
248
|
+
Unexpected(String),
|
|
249
|
+
/// The grammar needs this node and the source has none.
|
|
250
|
+
Missing(String),
|
|
251
|
+
/// An opening delimiter the rest of the file never closes.
|
|
252
|
+
Unclosed(String),
|
|
253
|
+
EndOfFile,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
impl SyntaxError {
|
|
257
|
+
/// `unexpected '<token>'`, `expected <kind>` or `unexpected end of file`,
|
|
258
|
+
/// plus a second `hint:` line for mistakes with a known fix.
|
|
259
|
+
pub fn message(&self) -> String {
|
|
260
|
+
match &self.kind {
|
|
261
|
+
SyntaxErrorKind::Unexpected(tok) => {
|
|
262
|
+
let mut msg = format!("unexpected '{}'", tok);
|
|
263
|
+
if tok == "()" {
|
|
264
|
+
msg.push_str(
|
|
265
|
+
"\n hint: `()` is not a value in Plum; a body with nothing to do \
|
|
266
|
+
can be `_ := 0` (or `todo` while it is unimplemented)",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
msg
|
|
270
|
+
}
|
|
271
|
+
SyntaxErrorKind::Missing(kind) => format!("expected {}", kind),
|
|
272
|
+
SyntaxErrorKind::Unclosed(open) => format!("unclosed '{}'", open),
|
|
273
|
+
SyntaxErrorKind::EndOfFile => "unexpected end of file".to_string(),
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
238
|
-
///
|
|
278
|
+
/// One precise error per damaged region of the parse tree, in source order.
|
|
279
|
+
///
|
|
280
|
+
/// Tree-sitter's recovery often wraps a whole item in an `ERROR` node, so the
|
|
239
|
-
///
|
|
281
|
+
/// node's own range is a poor position. Instead the innermost `ERROR`/MISSING
|
|
282
|
+
/// node (first in source order) inside each damaged region is located:
|
|
283
|
+
/// - a MISSING node is `expected <kind>`;
|
|
284
|
+
/// - an `ERROR` holding a single skipped token is that token (`foo(1, 2` followed by
|
|
285
|
+
/// `y := 3` blames `y`);
|
|
286
|
+
/// - an `ERROR` that only wraps tokens the parser had already accepted (it
|
|
287
|
+
/// gave up on them after seeing what came next) blames the first token
|
|
288
|
+
/// after it, or end of file when nothing follows.
|
|
240
|
-
fn
|
|
289
|
+
pub fn syntaxErrors(root: tree_sitter::Node, text: &str) -> Vec<SyntaxError> {
|
|
290
|
+
let mut out: Vec<SyntaxError> = Vec::new();
|
|
291
|
+
collectSyntaxErrors(root, root, text, &mut out);
|
|
292
|
+
out.dedup_by(|a, b| a.start == b.start);
|
|
293
|
+
out
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
fn collectSyntaxErrors(
|
|
297
|
+
root: tree_sitter::Node,
|
|
298
|
+
node: tree_sitter::Node,
|
|
299
|
+
text: &str,
|
|
300
|
+
out: &mut Vec<SyntaxError>,
|
|
301
|
+
) {
|
|
302
|
+
if !(node.has_error() || node.is_error() || node.is_missing()) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
241
305
|
if node.is_error() || node.is_missing() {
|
|
306
|
+
if let Some(inner) = innermostError(node) {
|
|
307
|
+
out.push(locateError(inner, root, text));
|
|
308
|
+
}
|
|
242
|
-
return
|
|
309
|
+
return;
|
|
243
310
|
}
|
|
244
311
|
let mut cursor = node.walk();
|
|
245
312
|
for child in node.children(&mut cursor) {
|
|
313
|
+
collectSyntaxErrors(root, child, text, out);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/// The first `ERROR`/MISSING node in source order that contains no other.
|
|
318
|
+
fn innermostError(node: tree_sitter::Node) -> Option<tree_sitter::Node> {
|
|
319
|
+
if !(node.has_error() || node.is_error() || node.is_missing()) {
|
|
320
|
+
return None;
|
|
321
|
+
}
|
|
322
|
+
let mut cursor = node.walk();
|
|
323
|
+
for child in node.children(&mut cursor) {
|
|
246
|
-
if let Some(e) =
|
|
324
|
+
if let Some(e) = innermostError(child) {
|
|
247
325
|
return Some(e);
|
|
248
326
|
}
|
|
249
327
|
}
|
|
328
|
+
(node.is_error() || node.is_missing()).then_some(node)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
fn locateError(err: tree_sitter::Node, root: tree_sitter::Node, text: &str) -> SyntaxError {
|
|
332
|
+
if err.is_missing() {
|
|
333
|
+
return SyntaxError {
|
|
334
|
+
start: err.start_position(),
|
|
335
|
+
end: err.end_position(),
|
|
336
|
+
kind: SyntaxErrorKind::Missing(err.kind().to_string()),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
let skipped_token = err.child_count() == 0
|
|
340
|
+
|| (err.child_count() == 1 && err.child(0).is_some_and(|c| c.child_count() == 0));
|
|
341
|
+
let token = if skipped_token {
|
|
342
|
+
Some(err)
|
|
343
|
+
} else {
|
|
344
|
+
leafStartingAtOrAfter(root, err.end_byte())
|
|
345
|
+
};
|
|
346
|
+
let eof = || {
|
|
347
|
+
let end = lastLeaf(root).map_or(root.end_position(), |l| l.end_position());
|
|
348
|
+
SyntaxError {
|
|
349
|
+
start: end,
|
|
350
|
+
end,
|
|
351
|
+
kind: SyntaxErrorKind::EndOfFile,
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
if skipped_token && err.child_count() == 1 {
|
|
355
|
+
let tok = err.child(0).unwrap();
|
|
356
|
+
// An opener the file never closes, wherever the parser gave up on it.
|
|
357
|
+
if let Some(closer) = closerOf(tok.kind()) {
|
|
358
|
+
if !isClosed(root, tok, closer) {
|
|
359
|
+
return SyntaxError {
|
|
360
|
+
start: tok.start_position(),
|
|
361
|
+
end: tok.end_position(),
|
|
362
|
+
kind: SyntaxErrorKind::Unclosed(tok.kind().to_string()),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// A trailing operator or keyword is a body cut short, not a stray token.
|
|
367
|
+
if !tok.is_named()
|
|
368
|
+
&& !matches!(tok.kind(), ")" | "]" | "}")
|
|
369
|
+
&& leafStartingAtOrAfter(root, err.end_byte()).is_none()
|
|
370
|
+
{
|
|
371
|
+
return eof();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
let Some(token) = token else {
|
|
375
|
+
return eof();
|
|
376
|
+
};
|
|
377
|
+
let (mut end_byte, mut end) = (token.end_byte(), token.end_position());
|
|
378
|
+
// An empty `()` / `[]` / `{}` reads as one token.
|
|
379
|
+
if let Some(closer) = closerOf(token.kind()) {
|
|
380
|
+
if let Some(next) = leafStartingAtOrAfter(root, token.end_byte()) {
|
|
381
|
+
if next.kind() == closer && next.start_byte() == token.end_byte() {
|
|
382
|
+
end_byte = next.end_byte();
|
|
383
|
+
end = next.end_position();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
let raw = text.get(token.start_byte()..end_byte).unwrap_or("");
|
|
388
|
+
let line = raw.lines().next().unwrap_or("").trim();
|
|
389
|
+
let shown: String = if line.chars().count() > 40 {
|
|
390
|
+
line.chars().take(40).collect::<String>() + "..."
|
|
391
|
+
} else {
|
|
392
|
+
line.to_string()
|
|
393
|
+
};
|
|
394
|
+
SyntaxError {
|
|
395
|
+
start: token.start_position(),
|
|
396
|
+
end,
|
|
397
|
+
kind: SyntaxErrorKind::Unexpected(shown),
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
fn closerOf(kind: &str) -> Option<&'static str> {
|
|
402
|
+
match kind {
|
|
403
|
+
"(" => Some(")"),
|
|
404
|
+
"[" => Some("]"),
|
|
405
|
+
"{" => Some("}"),
|
|
406
|
+
_ => None,
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/// Whether `open` has a matching `closer` later in the file, counting nesting.
|
|
411
|
+
fn isClosed(root: tree_sitter::Node, open: tree_sitter::Node, closer: &str) -> bool {
|
|
412
|
+
let mut depth = 1usize;
|
|
413
|
+
let mut at = open.end_byte();
|
|
414
|
+
while let Some(leaf) = leafStartingAtOrAfter(root, at) {
|
|
415
|
+
if leaf.kind() == open.kind() {
|
|
416
|
+
depth += 1;
|
|
417
|
+
} else if leaf.kind() == closer {
|
|
418
|
+
depth -= 1;
|
|
419
|
+
if depth == 0 {
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
at = leaf.end_byte();
|
|
424
|
+
}
|
|
425
|
+
false
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/// The first non-comment leaf whose start is at or after `byte`.
|
|
429
|
+
fn leafStartingAtOrAfter(node: tree_sitter::Node, byte: usize) -> Option<tree_sitter::Node> {
|
|
430
|
+
if node.end_byte() <= byte && node.child_count() > 0 {
|
|
431
|
+
return None;
|
|
432
|
+
}
|
|
433
|
+
if node.child_count() == 0 {
|
|
434
|
+
return (node.start_byte() >= byte
|
|
435
|
+
&& !node.is_extra()
|
|
436
|
+
&& node.end_byte() > node.start_byte())
|
|
437
|
+
.then_some(node);
|
|
438
|
+
}
|
|
439
|
+
let mut cursor = node.walk();
|
|
440
|
+
for child in node.children(&mut cursor) {
|
|
441
|
+
if let Some(l) = leafStartingAtOrAfter(child, byte) {
|
|
442
|
+
return Some(l);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
250
445
|
None
|
|
251
446
|
}
|
|
252
447
|
|
|
448
|
+
fn lastLeaf(node: tree_sitter::Node) -> Option<tree_sitter::Node> {
|
|
449
|
+
let mut cursor = node.walk();
|
|
450
|
+
let children: Vec<_> = node.children(&mut cursor).collect();
|
|
451
|
+
for child in children.into_iter().rev() {
|
|
452
|
+
if let Some(l) = lastLeaf(child) {
|
|
453
|
+
return Some(l);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
(node.child_count() == 0 && !node.is_extra() && node.end_byte() > node.start_byte())
|
|
457
|
+
.then_some(node)
|
|
458
|
+
}
|
|
459
|
+
|
|
253
460
|
thread_local! {
|
|
254
461
|
static MERGED_LIT_ERRORS: std::cell::RefCell<Vec<(usize, usize, String)>> =
|
|
255
462
|
std::cell::RefCell::new(Vec::new());
|
plum-core/tests/loader_test.rs
CHANGED
|
@@ -267,6 +267,58 @@ fn removedSyntaxIsAParseError() {
|
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
fn parseErrorOf(src: &str) -> String {
|
|
271
|
+
let dir = TempDir::new().unwrap();
|
|
272
|
+
let entry = dir.path().join("main.plum");
|
|
273
|
+
fs::write(&entry, src).unwrap();
|
|
274
|
+
loadAndMerge(&entry, dir.path()).unwrap_err()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
#[test]
|
|
278
|
+
fn parseErrorBlamesTheTokenNotTheWholeFunction() {
|
|
279
|
+
let err = parseErrorOf(
|
|
280
|
+
"import std/Float\n\nexport fun update(w: Float, dt: Float) =\n ()\n\nfun ok() =\n _ := 1.0\n",
|
|
281
|
+
);
|
|
282
|
+
assert!(
|
|
283
|
+
err.contains("at line 4, column 3: unexpected '()'"),
|
|
284
|
+
"{err}"
|
|
285
|
+
);
|
|
286
|
+
assert!(err.contains("hint:") && err.contains("_ := 0"), "{err}");
|
|
287
|
+
assert!(!err.contains("export fun"), "{err}");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#[test]
|
|
291
|
+
fn parseErrorOnStrayTokenMidExpression() {
|
|
292
|
+
let err = parseErrorOf("fun f() =\n x := 1 + * 2\n");
|
|
293
|
+
assert!(
|
|
294
|
+
err.contains("at line 2, column 12: unexpected '*'"),
|
|
295
|
+
"{err}"
|
|
296
|
+
);
|
|
297
|
+
assert!(!err.contains("hint:"), "{err}");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
#[test]
|
|
301
|
+
fn parseErrorOnMissingClosingDelimiter() {
|
|
302
|
+
let err = parseErrorOf("fun f() =\n x := (1 + 2\n");
|
|
303
|
+
assert!(err.contains("at line 2, column 8: unclosed '('"), "{err}");
|
|
304
|
+
let err = parseErrorOf("fun f() =\n x := foo(1, 2\n y := 3\n\nfun g() =\n z := 1\n");
|
|
305
|
+
assert!(err.contains("at line 3, column 3: unexpected 'y'"), "{err}");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#[test]
|
|
309
|
+
fn parseErrorAtEndOfFile() {
|
|
310
|
+
let err = parseErrorOf("fun f() =\n x := 1 +\n");
|
|
311
|
+
assert!(
|
|
312
|
+
err.contains("at line 2, column 11: unexpected end of file"),
|
|
313
|
+
"{err}"
|
|
314
|
+
);
|
|
315
|
+
let err = parseErrorOf("fun f() =\n x := foo(1, 2");
|
|
316
|
+
assert!(
|
|
317
|
+
err.contains("at line 2, column 16: unexpected end of file"),
|
|
318
|
+
"{err}"
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
270
322
|
#[test]
|
|
271
323
|
fn duplicate_const_in_one_body() {
|
|
272
324
|
let dir = TempDir::new().unwrap();
|
plum-lsp/src/main.rs
CHANGED
|
@@ -53,46 +53,30 @@ impl Backend {
|
|
|
53
53
|
.expect("tree-sitter parse returned None")
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
///
|
|
56
|
+
/// Syntax diagnostics from the parse tree, one per damaged region, each
|
|
57
|
+
/// on the token tree-sitter could not fit (`plum_core::syntaxErrors`),
|
|
57
|
-
///
|
|
58
|
+
/// not on the item its recovery wrapped in an ERROR node.
|
|
58
|
-
fn syntaxDiagnostics(tree: &tree_sitter::Tree) -> Vec<Diagnostic> {
|
|
59
|
+
fn syntaxDiagnostics(tree: &tree_sitter::Tree, source: &str) -> Vec<Diagnostic> {
|
|
60
|
+
plum_core::syntaxErrors(tree.root_node(), source)
|
|
61
|
+
.into_iter()
|
|
62
|
+
.map(|e| {
|
|
59
|
-
|
|
63
|
+
let mut start = e.start;
|
|
60
|
-
let mut cursor = tree.walk();
|
|
61
|
-
|
|
64
|
+
// A zero-width range (end of file, a missing node) underlines nothing.
|
|
62
|
-
|
|
65
|
+
if e.end == e.start && start.column > 0 {
|
|
63
|
-
|
|
66
|
+
start.column -= 1;
|
|
64
|
-
|
|
67
|
+
}
|
|
65
|
-
|
|
68
|
+
Diagnostic {
|
|
66
69
|
range: Range {
|
|
67
70
|
start: Position::new(start.row as u32, start.column as u32),
|
|
68
|
-
end: Position::new(end.row as u32, end.column as u32),
|
|
71
|
+
end: Position::new(e.end.row as u32, e.end.column as u32),
|
|
69
72
|
},
|
|
70
73
|
severity: Some(DiagnosticSeverity::ERROR),
|
|
71
74
|
source: Some("plum-syntax".into()),
|
|
72
|
-
message:
|
|
75
|
+
message: e.message(),
|
|
73
|
-
format!("missing {}", node.kind())
|
|
74
|
-
} else {
|
|
75
|
-
"syntax error".into()
|
|
76
|
-
},
|
|
77
76
|
..Default::default()
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
// Manual depth-first walk since `TreeCursor` has no built-in visitor.
|
|
82
|
-
loop {
|
|
83
|
-
visit(cursor.node());
|
|
84
|
-
if cursor.goto_first_child() {
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
loop {
|
|
88
|
-
if cursor.goto_next_sibling() {
|
|
89
|
-
break;
|
|
90
77
|
}
|
|
91
|
-
if !cursor.goto_parent() {
|
|
92
|
-
return out;
|
|
93
|
-
|
|
78
|
+
})
|
|
94
|
-
|
|
79
|
+
.collect()
|
|
95
|
-
}
|
|
96
80
|
}
|
|
97
81
|
|
|
98
82
|
/// Diagnostics from `plum_checker::checkSource`, run against the live
|
|
@@ -191,7 +175,7 @@ impl Backend {
|
|
|
191
175
|
None => return,
|
|
192
176
|
};
|
|
193
177
|
let tree = Self::parseTree(&text);
|
|
194
|
-
let mut diagnostics = Self::syntaxDiagnostics(&tree);
|
|
178
|
+
let mut diagnostics = Self::syntaxDiagnostics(&tree, &text);
|
|
195
179
|
if diagnostics.is_empty() {
|
|
196
180
|
if let Some(mut typeErrors) = self.typeDiagnostics(&uri, &text) {
|
|
197
181
|
diagnostics.append(&mut typeErrors);
|
|
@@ -389,7 +373,7 @@ mod tests {
|
|
|
389
373
|
let path = repoRoot().join("plum-examples/seq.plum");
|
|
390
374
|
let text = std::fs::read_to_string(&path).unwrap();
|
|
391
375
|
let tree = Backend::parseTree(&text);
|
|
392
|
-
assert!(Backend::syntaxDiagnostics(&tree).is_empty());
|
|
376
|
+
assert!(Backend::syntaxDiagnostics(&tree, &text).is_empty());
|
|
393
377
|
let diags = typeDiagnosticsFor(&path, &repoRoot(), &text).expect("seq.plum loads");
|
|
394
378
|
assert!(
|
|
395
379
|
diags.is_empty(),
|
|
@@ -412,7 +396,10 @@ mod tests {
|
|
|
412
396
|
let path = repoRoot().join("plum-examples").join(f);
|
|
413
397
|
let text = std::fs::read_to_string(&path).unwrap();
|
|
414
398
|
let tree = Backend::parseTree(&text);
|
|
399
|
+
assert!(
|
|
415
|
-
|
|
400
|
+
Backend::syntaxDiagnostics(&tree, &text).is_empty(),
|
|
401
|
+
"{f}: syntax"
|
|
402
|
+
);
|
|
416
403
|
let diags = typeDiagnosticsFor(&path, &repoRoot(), &text).expect("loads");
|
|
417
404
|
assert!(
|
|
418
405
|
diags.is_empty(),
|
|
@@ -630,7 +617,7 @@ mod tests {
|
|
|
630
617
|
let text = std::fs::read_to_string(&path).unwrap();
|
|
631
618
|
let tree = Backend::parseTree(&text);
|
|
632
619
|
assert!(
|
|
633
|
-
Backend::syntaxDiagnostics(&tree).is_empty(),
|
|
620
|
+
Backend::syntaxDiagnostics(&tree, &text).is_empty(),
|
|
634
621
|
"{path:?}: syntax"
|
|
635
622
|
);
|
|
636
623
|
let diags = typeDiagnosticsFor(&path, &repoRoot(), &text).expect("loads");
|
|
@@ -691,12 +678,12 @@ mod tests {
|
|
|
691
678
|
s
|
|
692
679
|
};
|
|
693
680
|
let tree = Backend::parseTree(&src);
|
|
694
|
-
let d = Backend::syntaxDiagnostics(&tree);
|
|
681
|
+
let d = Backend::syntaxDiagnostics(&tree, &src);
|
|
695
682
|
// The one mixed block (items + statements) is parsed as two halves below.
|
|
696
683
|
if block.contains("\nintNum") {
|
|
697
684
|
let (items, stmts) = block.split_at(block.find("\nintNum").unwrap() + 1);
|
|
698
685
|
assert!(
|
|
699
|
-
Backend::syntaxDiagnostics(&Backend::parseTree(items)).is_empty(),
|
|
686
|
+
Backend::syntaxDiagnostics(&Backend::parseTree(items), items).is_empty(),
|
|
700
687
|
"items:\n{items}"
|
|
701
688
|
);
|
|
702
689
|
let wrapped = format!(
|
|
@@ -707,7 +694,7 @@ mod tests {
|
|
|
707
694
|
.collect::<String>()
|
|
708
695
|
);
|
|
709
696
|
assert!(
|
|
710
|
-
Backend::syntaxDiagnostics(&Backend::parseTree(&wrapped)).is_empty(),
|
|
697
|
+
Backend::syntaxDiagnostics(&Backend::parseTree(&wrapped), &wrapped).is_empty(),
|
|
711
698
|
"stmts:\n{stmts}"
|
|
712
699
|
);
|
|
713
700
|
} else {
|
|
@@ -717,4 +704,18 @@ mod tests {
|
|
|
717
704
|
}
|
|
718
705
|
assert!(n > 20, "found only {n} README snippets");
|
|
719
706
|
}
|
|
707
|
+
|
|
708
|
+
#[test]
|
|
709
|
+
fn syntaxDiagnosticUnderlinesTheTokenNotTheFunction() {
|
|
710
|
+
let src = "import std/Float\n\nexport fun update(w: Float, dt: Float) =\n ()\n\nfun ok() =\n _ := 1.0\n";
|
|
711
|
+
let d = Backend::syntaxDiagnostics(&Backend::parseTree(src), src);
|
|
712
|
+
assert_eq!(d.len(), 1, "{d:?}");
|
|
713
|
+
assert_eq!(d[0].range.start, Position::new(3, 2));
|
|
714
|
+
assert_eq!(d[0].range.end, Position::new(3, 4));
|
|
715
|
+
assert!(
|
|
716
|
+
d[0].message.starts_with("unexpected '()'"),
|
|
717
|
+
"{}",
|
|
718
|
+
d[0].message
|
|
719
|
+
);
|
|
720
|
+
}
|
|
720
721
|
}
|