plum

#treesitter#compiler#wasm

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 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
- pub use loader::{loadAndMerge, loadAndMergeWithOverrides};
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) = firstErrorNode(root) {
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 {}: unexpected token near '{}'",
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.utf8_text(text.as_bytes()).unwrap_or("").trim(),
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
- /// The first `ERROR` (or MISSING-token) node in `node`'s subtree, depth-first,
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
- /// or `None` if the parse was clean. Every `ERROR`/missing node's own text
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
- /// range points at (or immediately follows) the actual offending token,
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
- /// which is more useful for a diagnostic than the enclosing node's range.
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 firstErrorNode(node: tree_sitter::Node) -> Option<tree_sitter::Node> {
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 Some(node);
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) = firstErrorNode(child) {
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
- /// Real-span diagnostics straight from the parse tree: any ERROR or
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
- /// MISSING node becomes one diagnostic at its own range.
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
- let mut out = Vec::new();
63
+ let mut start = e.start;
60
- let mut cursor = tree.walk();
61
- let mut visit = |node: tree_sitter::Node| {
64
+ // A zero-width range (end of file, a missing node) underlines nothing.
62
- if node.is_error() || node.is_missing() {
65
+ if e.end == e.start && start.column > 0 {
63
- let start = node.start_position();
66
+ start.column -= 1;
64
- let end = node.end_position();
67
+ }
65
- out.push(Diagnostic {
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: if node.is_missing() {
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
- assert!(Backend::syntaxDiagnostics(&tree).is_empty(), "{f}: syntax");
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
  }