plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


9b45c3f — Peter John 2026-11-02T15:39:26+05:30
Report an unclosed delimiter at its opener; hint suggests a bare return body
plum-cli/tests/compile_tests.rs CHANGED
@@ -74,3 +74,36 @@ fn compileWithImportResolvesViaLibPath() {
74
74
  let bytes = std::fs::read(out_path).expect("output wasm not found");
75
75
  assert_eq!(&bytes[0..4], b"\0asm");
76
76
  }
77
+
78
+ /// The body the parse-error hint for `()` suggests must itself compile, with
79
+ /// only the imports the failing file already had.
80
+ #[test]
81
+ fn returnBodySuggestedByTheParseErrorHintCompiles() {
82
+ let dir = freshTempDir("return_hint");
83
+ let src_path = dir.join("h.plum");
84
+ std::fs::write(
85
+ &src_path,
86
+ "import std/Float\n\nexport fun update(w: Float, dt: Float) =\n return\n",
87
+ )
88
+ .unwrap();
89
+ let out_path = dir.join("h.wasm");
90
+ let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
91
+ .parent()
92
+ .unwrap();
93
+ let output = Command::new(plumBin())
94
+ .args([
95
+ "compile",
96
+ src_path.to_str().unwrap(),
97
+ "-o",
98
+ out_path.to_str().unwrap(),
99
+ "--lib-path",
100
+ repo.to_str().unwrap(),
101
+ ])
102
+ .output()
103
+ .expect("failed to run plum compile");
104
+ assert!(
105
+ output.status.success(),
106
+ "{}",
107
+ String::from_utf8_lossy(&output.stderr)
108
+ );
109
+ }
plum-core/src/loader.rs CHANGED
@@ -263,7 +263,7 @@ impl SyntaxError {
263
263
  if tok == "()" {
264
264
  msg.push_str(
265
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)",
266
+ is `return` (or `todo` while it is unimplemented)",
267
267
  );
268
268
  }
269
269
  msg
@@ -285,7 +285,8 @@ impl SyntaxError {
285
285
  /// `y := 3` blames `y`);
286
286
  /// - an `ERROR` that only wraps tokens the parser had already accepted (it
287
287
  /// gave up on them after seeing what came next) blames the first token
288
- /// after it, or end of file when nothing follows.
288
+ /// after it, or end of file when nothing follows (reported as the
289
+ /// innermost unclosed delimiter when the file ends inside one).
289
290
  pub fn syntaxErrors(root: tree_sitter::Node, text: &str) -> Vec<SyntaxError> {
290
291
  let mut out: Vec<SyntaxError> = Vec::new();
291
292
  collectSyntaxErrors(root, root, text, &mut out);
@@ -344,6 +345,13 @@ fn locateError(err: tree_sitter::Node, root: tree_sitter::Node, text: &str) -> S
344
345
  leafStartingAtOrAfter(root, err.end_byte())
345
346
  };
346
347
  let eof = || {
348
+ if let Some(open) = innermostUnclosed(root) {
349
+ return SyntaxError {
350
+ start: open.start_position(),
351
+ end: open.end_position(),
352
+ kind: SyntaxErrorKind::Unclosed(open.kind().to_string()),
353
+ };
354
+ }
347
355
  let end = lastLeaf(root).map_or(root.end_position(), |l| l.end_position());
348
356
  SyntaxError {
349
357
  start: end,
@@ -407,6 +415,25 @@ fn closerOf(kind: &str) -> Option<&'static str> {
407
415
  }
408
416
  }
409
417
 
418
+ /// The last opening delimiter still open at the end of the file, if any.
419
+ fn innermostUnclosed(root: tree_sitter::Node) -> Option<tree_sitter::Node> {
420
+ let mut stack: Vec<tree_sitter::Node> = Vec::new();
421
+ let mut at = 0;
422
+ while let Some(leaf) = leafStartingAtOrAfter(root, at) {
423
+ if closerOf(leaf.kind()).is_some() {
424
+ stack.push(leaf);
425
+ } else if matches!(leaf.kind(), ")" | "]" | "}") {
426
+ if let Some(top) = stack.last() {
427
+ if closerOf(top.kind()) == Some(leaf.kind()) {
428
+ stack.pop();
429
+ }
430
+ }
431
+ }
432
+ at = leaf.end_byte();
433
+ }
434
+ stack.pop()
435
+ }
436
+
410
437
  /// Whether `open` has a matching `closer` later in the file, counting nesting.
411
438
  fn isClosed(root: tree_sitter::Node, open: tree_sitter::Node, closer: &str) -> bool {
412
439
  let mut depth = 1usize;
plum-core/tests/loader_test.rs CHANGED
@@ -283,7 +283,10 @@ fn parseErrorBlamesTheTokenNotTheWholeFunction() {
283
283
  err.contains("at line 4, column 3: unexpected '()'"),
284
284
  "{err}"
285
285
  );
286
+ assert!(
286
- assert!(err.contains("hint:") && err.contains("_ := 0"), "{err}");
287
+ err.contains("hint:") && err.contains("is `return`"),
288
+ "{err}"
289
+ );
287
290
  assert!(!err.contains("export fun"), "{err}");
288
291
  }
289
292
 
@@ -305,6 +308,18 @@ fn parseErrorOnMissingClosingDelimiter() {
305
308
  assert!(err.contains("at line 3, column 3: unexpected 'y'"), "{err}");
306
309
  }
307
310
 
311
+ #[test]
312
+ fn unclosedOpenerAtEndOfFileIsReportedAtTheOpener() {
313
+ let err = parseErrorOf("import std/Int\n\nfun f(a: Int) -> Int =\n g(a, 2\n");
314
+ assert!(err.contains("at line 4, column 4: unclosed '('"), "{err}");
315
+ let err = parseErrorOf("fun f() =\n x := [1, 2\n");
316
+ assert!(err.contains("at line 2, column 8: unclosed '['"), "{err}");
317
+ let err = parseErrorOf("fun f() =\n x := g(h(1), 2\n");
318
+ assert!(err.contains("at line 2, column 9: unclosed '('"), "{err}");
319
+ let err = parseErrorOf("fun f() =\n x := { 1 + 2\n");
320
+ assert!(err.contains("at line 2, column 8: unclosed '{'"), "{err}");
321
+ }
322
+
308
323
  #[test]
309
324
  fn parseErrorAtEndOfFile() {
310
325
  let err = parseErrorOf("fun f() =\n x := 1 +\n");
@@ -313,8 +328,10 @@ fn parseErrorAtEndOfFile() {
313
328
  "{err}"
314
329
  );
315
330
  let err = parseErrorOf("fun f() =\n x := foo(1, 2");
331
+ assert!(err.contains("at line 2, column 11: unclosed '('"), "{err}");
332
+ let err = parseErrorOf("fun f() =\n if true\n x := 1\n else\n");
316
333
  assert!(
317
- err.contains("at line 2, column 16: unexpected end of file"),
334
+ err.contains("at line 4, column 7: unexpected end of file"),
318
335
  "{err}"
319
336
  );
320
337
  }