plum

#treesitter#compiler#wasm

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

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


0aa74fb — Peter John 2026-10-26T21:20:45+05:30
docs(editors): iteration cheat sheet, value enum docs; highlight/LSP for for/extend/Seq
README.md CHANGED
@@ -285,6 +285,24 @@ while i < units.length()
285
285
 
286
286
  List and Map have no closure-taking iteration methods; closures remain in the language for passing behavior around as values (`Num` below, `Try.map`). See [`plum-examples/seq.plum`](plum-examples/seq.plum) (one test per adapter and terminal), [`plum-examples/chains.plum`](plum-examples/chains.plum) and [`plum-examples/forms.plum`](plum-examples/forms.plum).
287
287
 
288
+ #### Iteration cheat sheet
289
+
290
+ | Go / Rust / streams | Plum |
291
+ |---|---|
292
+ | `for _, u := range units` / `for u in &units` | `for u := units` |
293
+ | `for i, u := range units` / `.iter().enumerate()` | `for i, u := units` |
294
+ | `for k, v := range m` / `for (k, v) in &m` | `for k, v := m` |
295
+ | `for i := 0; i < n; i++` / `for i in 0..n` | `for i := range(0, n)` |
296
+ | `.iter().filter(p).collect()` / `stream.filter(p)` | `for u := units.filter()` then the predicate as the body |
297
+ | `.iter().map(f).collect()` / `stream.map(f)` | `for u := units.map()` then the mapped value as the body |
298
+ | `.iter().find(p)` / `findFirst(p)` | `for u := units.find()` (`-> T?`) |
299
+ | `.iter().fold(0, f)` / `reduce(0, f)` | `for acc, u := units.fold(0)` |
300
+ | `.iter().take(n).skip(m)` / `limit(n).skip(m)` | `units.skip(m).take(n)` |
301
+ | `.iter().step_by(n)` / `.rev()` / `.zip(o)` / `chunks(n)` | `stepBy(n)` / `reversed()` / `zip(o)` / `chunks(n)` |
302
+ | `.count()` / `.any(p)` / `.all(p)` / `forEach(f)` | `count()` / `any()` / `all()` / `each()` |
303
+ | `if p(x) { continue }` | `, if cond` clause after the source |
304
+ | generator / `yield` | `fun* name(..) *T` with `yield` |
305
+
288
306
  #### `extend`
289
307
 
290
308
  `extend <Type> =` followed by indented methods adds methods (`fun` and `fun*`) to a compiler primitive without declaring a dummy `enum X = | X` just to hang them on. It is only allowed in `plum-std`, and only for built-in types (`Seq`, `Int`, `Float`, `Bool`, `Str`, `Byte`, `ByteSlice`, `Array`); anywhere else it is an error (`extend is only allowed for built-in types in std`).
@@ -343,6 +361,23 @@ enum Step(n: Int) = # a shared field on every variant ...
343
361
  | ReadMax(20)
344
362
  ```
345
363
 
364
+ ### `value enum`
365
+
366
+ `value enum` declares a small, copy-semantics value type (a vector, a color, a rectangle) that is flattened into wasm locals instead of being heap-allocated:
367
+
368
+ ```plum
369
+ value enum Vec2 =
370
+ | Vec2(x: Float, y: Float)
371
+
372
+ fun add(self, o: Vec2) -> Vec2 =
373
+ Vec2(x: self.x + o.x, y: self.y + o.y)
374
+
375
+ value enum Rect =
376
+ | Rect(pos: Vec2, size: Vec2) # a field may be another value enum
377
+ ```
378
+
379
+ It has exactly one constructor, named like the type; no generics; fields are only `Int`, `Float`, `Bool` or other value enums, and a value enum can't contain itself. Values are copied on assignment and when passed, and are boxed only inside generic containers (`List[Vec2]`, `Vec2?`). Methods, `match` and `?` work as for any enum. See [`plum-examples/values.plum`](plum-examples/values.plum).
380
+
346
381
  ## Generics
347
382
 
348
383
  ```plum
plum-lsp/src/completion.rs CHANGED
@@ -26,7 +26,7 @@ use tower_lsp::lsp_types::*;
26
26
 
27
27
  const KEYWORDS: &[&str] = &[
28
28
  "fun", "fun*", "yield", "crash", "??", "value", "enum", "extend", "import", "module", "extern", "if", "else", "while", "for", "match",
29
- "case", "when", "break", "continue", "return", "todo", "assert", "test", "self", "True",
29
+ "when", "break", "continue", "return", "todo", "assert", "test", "self", "True",
30
30
  "False",
31
31
  ];
32
32
 
@@ -250,6 +250,7 @@ pub fn memberItems(
250
250
  receiver: &str,
251
251
  global_env: &TypeEnv,
252
252
  ctx: &CheckCtx,
253
+ stars: &[ast::Fn],
253
254
  ) -> Vec<CompletionItem> {
254
255
  let Some(f) = cursor_fn else {
255
256
  return Vec::new();
@@ -297,6 +298,29 @@ pub fn memberItems(
297
298
  ..Default::default()
298
299
  });
299
300
  }
301
+ // `fun*` methods are expanded by the loop lowering and are not in the
302
+ // checker's method tables; they come from the source's `stars`. An
303
+ // iterable (`length()`+`at(i)`, or an `entries()` method) also reaches the
304
+ // `extend Seq[T]` ones through the checker's method fallback.
305
+ let has = |m: &str| {
306
+ ctx.methods.contains_key(&(recv_name.clone(), m.to_string()))
307
+ || stars.iter().any(|f| f.type_param.as_deref() == Some(recv_name.as_str()) && f.name == m)
308
+ };
309
+ let iterable = recv_name != "Seq" && ((has("length") && has("at")) || has("entries"));
310
+ for f in stars {
311
+ let owner = f.type_param.as_deref();
312
+ let own = owner == Some(recv_name.as_str());
313
+ let seq = iterable && owner == Some("Seq");
314
+ if (!own && !seq) || items.iter().any(|i| i.label == f.name) {
315
+ continue;
316
+ }
317
+ items.push(CompletionItem {
318
+ label: f.name.clone(),
319
+ kind: Some(CompletionItemKind::METHOD),
320
+ detail: Some(if own { "fun*".to_string() } else { "fun* (Seq)".to_string() }),
321
+ ..Default::default()
322
+ });
323
+ }
300
324
  for info in ctx.enum_variants.values() {
301
325
  if info.enum_name != recv_name {
302
326
  continue;
plum-lsp/src/main.rs CHANGED
@@ -23,7 +23,7 @@
23
23
  #![allow(non_snake_case)]
24
24
 
25
25
  use std::collections::HashMap;
26
- use std::path::PathBuf;
26
+ use std::path::{Path, PathBuf};
27
27
  use std::sync::OnceLock;
28
28
 
29
29
  use tokio::sync::Mutex;
@@ -104,37 +104,7 @@ impl Backend {
104
104
  /// flash a bogus diagnostic.
105
105
  fn typeDiagnostics(&self, uri: &Url, text: &str) -> Option<Vec<Diagnostic>> {
106
106
  let path = uri.to_file_path().ok()?;
107
- let canon = std::fs::canonicalize(&path).unwrap_or(path.clone());
108
- let mut overrides = HashMap::new();
109
- overrides.insert(canon, text.to_string());
110
- let ast = plum_core::loadAndMergeWithOverrides(&path, &self.libPath, &overrides).ok()?;
107
+ typeDiagnosticsFor(&path, &self.libPath, text)
111
- let lastLine = text.lines().count().max(1) as u32 - 1;
112
- match plum_checker::checkSource(&ast) {
113
- Ok(()) => Some(Vec::new()),
114
- Err(errors) => Some(
115
- errors
116
- .into_iter()
117
- .map(|e| {
118
- // `CheckError`'s position is 1-based and points at the
119
- // enclosing declaration, not a sub-expression — clamp
120
- // to the document and cover the whole line so an
121
- // editor still shows something sensible even if the
122
- // line itself is short/blank.
123
- let line = e.line.saturating_sub(1).min(lastLine as usize) as u32;
124
- Diagnostic {
125
- range: Range {
126
- start: Position::new(line, 0),
127
- end: Position::new(line, u32::MAX),
128
- },
129
- severity: Some(DiagnosticSeverity::ERROR),
130
- source: Some("plum-check".into()),
131
- message: e.message,
132
- ..Default::default()
133
- }
134
- })
135
- .collect(),
136
- ),
137
- }
138
108
  }
139
109
 
140
110
  /// Builds `plum_checker`'s global tables for the live buffer, the same
@@ -160,20 +130,16 @@ impl Backend {
160
130
  let own_source = plum_core::AstParser::new(&sanitized).parseSource(tree.root_node());
161
131
 
162
132
  if let Some(receiver) = completion::receiverBeforeDot(line_text, pos.character as usize) {
163
- let Some(merged) = self.mergedSource(uri, &sanitized) else {
133
+ let Ok(path) = uri.to_file_path() else {
164
134
  return Vec::new();
165
135
  };
166
- let (global_env, methods, enum_variants, enum_params) =
167
- plum_checker::buildGlobalTables(&merged);
168
- let min_required = plum_checker::buildMinRequiredArgs(&merged);
169
- let ctx = plum_checker::CheckCtx {
170
- methods: &methods,
171
- enum_variants: &enum_variants,
172
- enum_params: &enum_params,
136
+ return memberCompletionFor(
137
+ &path,
138
+ &self.libPath,
139
+ &sanitized,
173
- min_required: &min_required,
140
+ pos.line as usize + 1,
141
+ &receiver,
174
- };
142
+ );
175
- let cursor_fn = completion::enclosingFn(&own_source, pos.line as usize + 1);
176
- return completion::memberItems(cursor_fn, &receiver, &global_env, &ctx);
177
143
  }
178
144
 
179
145
  let mut items = completion::keywordItems();
@@ -303,6 +269,72 @@ impl LanguageServer for Backend {
303
269
  }
304
270
  }
305
271
 
272
+ /// `Backend::typeDiagnostics` without the `Backend` (so it is unit-testable):
273
+ /// loads `path` with `text` overriding what's on disk, then runs the checker.
274
+ fn typeDiagnosticsFor(path: &Path, libPath: &Path, text: &str) -> Option<Vec<Diagnostic>> {
275
+ let canon = std::fs::canonicalize(path).unwrap_or(path.to_path_buf());
276
+ let mut overrides = HashMap::new();
277
+ overrides.insert(canon, text.to_string());
278
+ let ast = plum_core::loadAndMergeWithOverrides(path, libPath, &overrides).ok()?;
279
+ let lastLine = text.lines().count().max(1) as u32 - 1;
280
+ match plum_checker::checkSource(&ast) {
281
+ Ok(()) => Some(Vec::new()),
282
+ Err(errors) => Some(
283
+ errors
284
+ .into_iter()
285
+ .map(|e| {
286
+ // `CheckError`'s position is 1-based and points at the
287
+ // enclosing declaration, not a sub-expression — clamp
288
+ // to the document and cover the whole line so an
289
+ // editor still shows something sensible even if the
290
+ // line itself is short/blank.
291
+ let line = e.line.saturating_sub(1).min(lastLine as usize) as u32;
292
+ Diagnostic {
293
+ range: Range {
294
+ start: Position::new(line, 0),
295
+ end: Position::new(line, u32::MAX),
296
+ },
297
+ severity: Some(DiagnosticSeverity::ERROR),
298
+ source: Some("plum-check".into()),
299
+ message: e.message,
300
+ ..Default::default()
301
+ }
302
+ })
303
+ .collect(),
304
+ ),
305
+ }
306
+ }
307
+
308
+ /// Member completion for `receiver.` at `cursor_line` (1-based) of the
309
+ /// (sanitized) buffer `text`; `Backend::completionItems`' member branch.
310
+ fn memberCompletionFor(
311
+ path: &Path,
312
+ libPath: &Path,
313
+ text: &str,
314
+ cursor_line: usize,
315
+ receiver: &str,
316
+ ) -> Vec<CompletionItem> {
317
+ let canon = std::fs::canonicalize(path).unwrap_or(path.to_path_buf());
318
+ let mut overrides = HashMap::new();
319
+ overrides.insert(canon, text.to_string());
320
+ let Ok(merged) = plum_core::loadAndMergeWithOverrides(path, libPath, &overrides) else {
321
+ return Vec::new();
322
+ };
323
+ let tree = Backend::parseTree(text);
324
+ let own_source = plum_core::AstParser::new(text).parseSource(tree.root_node());
325
+ let (global_env, methods, enum_variants, enum_params) =
326
+ plum_checker::buildGlobalTables(&merged);
327
+ let min_required = plum_checker::buildMinRequiredArgs(&merged);
328
+ let ctx = plum_checker::CheckCtx {
329
+ methods: &methods,
330
+ enum_variants: &enum_variants,
331
+ enum_params: &enum_params,
332
+ min_required: &min_required,
333
+ };
334
+ let cursor_fn = completion::enclosingFn(&own_source, cursor_line);
335
+ completion::memberItems(cursor_fn, receiver, &global_env, &ctx, &merged.stars)
336
+ }
337
+
306
338
  #[tokio::main]
307
339
  async fn main() {
308
340
  // `--lib-path` mirrors `plum-cli`'s own required flag (see
@@ -329,3 +361,66 @@ async fn main() {
329
361
  });
330
362
  Server::new(stdin, stdout, socket).serve(service).await;
331
363
  }
364
+
365
+ #[cfg(test)]
366
+ mod tests {
367
+ use super::*;
368
+
369
+ fn repoRoot() -> PathBuf {
370
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
371
+ }
372
+
373
+ /// The whole diagnostic path (`publishAll`: syntax diagnostics, then the
374
+ /// two-pass checker) must find nothing wrong in the Seq/fun*/for example.
375
+ #[test]
376
+ fn seqExampleHasNoDiagnostics() {
377
+ let path = repoRoot().join("plum-examples/seq.plum");
378
+ let text = std::fs::read_to_string(&path).unwrap();
379
+ let tree = Backend::parseTree(&text);
380
+ assert!(Backend::syntaxDiagnostics(&tree).is_empty());
381
+ let diags = typeDiagnosticsFor(&path, &repoRoot(), &text).expect("seq.plum loads");
382
+ assert!(diags.is_empty(), "unexpected diagnostics: {:?}", diags.iter().map(|d| &d.message).collect::<Vec<_>>());
383
+ }
384
+
385
+ #[test]
386
+ fn valuesAndLoopsExamplesHaveNoDiagnostics() {
387
+ for f in ["values.plum", "loops.plum", "chains.plum", "forms.plum"] {
388
+ let path = repoRoot().join("plum-examples").join(f);
389
+ let text = std::fs::read_to_string(&path).unwrap();
390
+ let tree = Backend::parseTree(&text);
391
+ assert!(Backend::syntaxDiagnostics(&tree).is_empty(), "{f}: syntax");
392
+ let diags = typeDiagnosticsFor(&path, &repoRoot(), &text).expect("loads");
393
+ assert!(diags.is_empty(), "{f}: {:?}", diags.iter().map(|d| &d.message).collect::<Vec<_>>());
394
+ }
395
+ }
396
+
397
+ /// `xs.` on a List offers List's own methods AND the Seq methods reached
398
+ /// through the iterable fallback.
399
+ #[test]
400
+ fn memberCompletionOnListIncludesSeqMethods() {
401
+ let dir = std::env::temp_dir().join(format!("plum-lsp-test-{}", std::process::id()));
402
+ std::fs::create_dir_all(&dir).unwrap();
403
+ let path = dir.join("t.plum");
404
+ let text = "module t\nimport std/Number\nimport std/List\nimport std/Seq\n\nfun main() =\n xs := List(1, 2, 3)\n xs.zzzzzz\n";
405
+ std::fs::write(&path, text).unwrap();
406
+ let items = memberCompletionFor(&path, &repoRoot(), text, 8, "xs");
407
+ let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
408
+ for want in ["length", "at", "filter", "map", "take", "fold", "toList"] {
409
+ assert!(labels.contains(&want), "missing {want} in {labels:?}");
410
+ }
411
+ let n = labels.iter().filter(|l| **l == "length").count();
412
+ assert_eq!(n, 1, "no duplicates");
413
+ let _ = std::fs::remove_dir_all(&dir);
414
+ }
415
+
416
+ #[test]
417
+ fn keywordsCurrent() {
418
+ let labels: Vec<String> = completion::keywordItems().into_iter().map(|i| i.label).collect();
419
+ for want in ["extend", "value", "fun*", "yield"] {
420
+ assert!(labels.iter().any(|l| l == want), "missing {want}");
421
+ }
422
+ for gone in ["inline", "block", "indexed", "case"] {
423
+ assert!(!labels.iter().any(|l| l == gone), "stale {gone}");
424
+ }
425
+ }
426
+ }
plum-tooling/tree-sitter-plum/queries/plum/highlights.scm CHANGED
@@ -1,3 +1,12 @@
1
+ ; Earlier patterns win on the same range (tree-sitter-highlight / Helix), so the
2
+ ; specific rules come first.
3
+ ; `*T` / `*(A, B)` after a `fun*` signature: what each `yield` hands to the `for`.
4
+ (yield_type "*" @punctuation.special)
5
+
6
+ ; `for _, v := m`: `_` is aliased to a var_identifier, so match it by text.
7
+ ((var_identifier) @variable.builtin
8
+ (#eq? @variable.builtin "_"))
9
+
1
10
  ; (mod_identifier) @constant
2
11
  (type_identifier) @type
3
12
  (fn_identifier) @function
@@ -112,4 +121,4 @@
112
121
 
113
122
  (tag_type (type_identifier) @constructor)
114
123
 
115
- "match" @keyword
124
+ "match" @keyword
plum-tooling/tree-sitter-plum/queries/plum/indents.scm CHANGED
@@ -1,5 +1,6 @@
1
1
  [
2
2
  (enum)
3
+ (extend)
3
4
  (fn)
4
5
  (for)
5
6
  (while)
@@ -15,6 +16,7 @@
15
16
  (while)
16
17
  (fn)
17
18
  (enum)
19
+ (extend)
18
20
  ] @extend
19
21
 
20
22
  [
plum-tooling/tree-sitter-plum/queries/plum/tags.scm CHANGED
@@ -12,3 +12,6 @@
12
12
 
13
13
  (fn_call
14
14
  function: (type_identifier) @name) @reference.call
15
+
16
+ (extend
17
+ name: (type_identifier) @name) @definition.class
plum-tooling/tree-sitter-plum/queries/plum/textobjects.scm CHANGED
@@ -9,3 +9,5 @@
9
9
 
10
10
  (comment) @comment.inside
11
11
  (comment) @comment.around
12
+
13
+ (extend) @class.around
plum-tooling/vscode-plum/syntaxes/plum.tmLanguage.json CHANGED
@@ -5,6 +5,9 @@
5
5
  {
6
6
  "include": "#comments"
7
7
  },
8
+ {
9
+ "include": "#yield-type"
10
+ },
8
11
  {
9
12
  "include": "#keywords"
10
13
  },
@@ -36,6 +39,22 @@
36
39
  }
37
40
  ]
38
41
  },
42
+ "yield-type": {
43
+ "comment": "the `*T` / `*(A, B)` yield type after a `fun*` signature (so it is not styled as multiplication)",
44
+ "match": "^(\\s*(?:fun\\*)\\s+[A-Za-z_][A-Za-z0-9_]*\\s*\\(.*\\)\\s*)(\\*)",
45
+ "captures": {
46
+ "1": {
47
+ "patterns": [
48
+ {
49
+ "include": "$self"
50
+ }
51
+ ]
52
+ },
53
+ "2": {
54
+ "name": "storage.type.yield.plum"
55
+ }
56
+ }
57
+ },
39
58
  "keywords": {
40
59
  "patterns": [
41
60
  {
@@ -44,7 +63,7 @@
44
63
  },
45
64
  {
46
65
  "name": "keyword.control.plum",
47
- "match": "\\b(import|in|return|continue|break|match|when|if|else|while|for|yield|assert|todo|crash)\\b"
66
+ "match": "\\b(import|return|continue|break|match|when|if|else|while|for|yield|assert|todo|crash)\\b"
48
67
  },
49
68
  {
50
69
  "name": "keyword.operator.range.plum",
@@ -83,7 +102,7 @@
83
102
  },
84
103
  {
85
104
  "name": "keyword.operator.assignment.plum",
86
- "match": "="
105
+ "match": ":?="
87
106
  }
88
107
  ]
89
108
  },