plum

#treesitter#compiler#wasm

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

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


18b7b69 — Peter John 2026-11-04T13:47:55+05:30
format: one argument per line for multi-line calls and field lists
plum-core/tests/formatter_test.rs CHANGED
@@ -463,3 +463,34 @@ fn format_fn_return_type() {
463
463
  "fun adder(n: Int) -> fn(Int) -> Int =\n |x| x + n\n\nfun k() -> fn(Int) -> fn(Int) -> Int =\n |x| |y| x\n",
464
464
  );
465
465
  }
466
+
467
+ /// A constructor call wrapped across lines gets one argument per line and its
468
+ /// `)` on its own line; one written on a single line stays on it.
469
+ #[test]
470
+ fn format_multiline_ctor_args_one_per_line() {
471
+ let input = "fun f() -> Pt =\n Pt(\n x: 1, y: 2,\n z: 3).g()\n\nfun h() -> Pt =\n Pt(x: 1, y: 2)\n";
472
+ let expected = "fun f() -> Pt =\n Pt(\n x: 1,\n y: 2,\n z: 3\n ).g()\n\nfun h() -> Pt =\n Pt(x: 1, y: 2)\n";
473
+ assert_eq!(formatSource(input).unwrap(), expected);
474
+ assertFormatsUnchanged(expected);
475
+ }
476
+
477
+ /// Same for ordinary call arguments (previously collapsed onto one line).
478
+ #[test]
479
+ fn format_multiline_call_args_one_per_line() {
480
+ let input = "fun f() -> Int =\n g(\n 1, 2)\n";
481
+ let expected = "fun f() -> Int =\n g(\n 1,\n 2\n )\n";
482
+ assert_eq!(formatSource(input).unwrap(), expected);
483
+ }
484
+
485
+ /// A record's field list written across lines stays one field per line
486
+ /// (previously collapsed onto one line); a one-line list stays on one line.
487
+ #[test]
488
+ fn format_multiline_record_fields_one_per_line() {
489
+ assertFormatsUnchanged("record Rec(\n a: Int,\n b: Int\n)\n\nrecord Sec(a: Int, b: Int)\n");
490
+ }
491
+
492
+ /// `mut` fields and `export fun` keep their space.
493
+ #[test]
494
+ fn format_mut_and_export_keywords() {
495
+ assertFormatsUnchanged("record World(mut s: Int, t: Int)\n\nexport fun f() -> Int =\n 1\n");
496
+ }
plum-examples/games/functional/game_state_view.plum CHANGED
@@ -73,8 +73,17 @@ fun nothing() -> SceneNode =
73
73
 
74
74
  fun cell(sheet: Int, x: Float, y: Float, size: Float, col: Int, row: Int) -> SceneNode =
75
75
  Sprite(
76
- tex: sheet, x: x, y: y, w: size, h: size, srcX: Float(col) * Rules.Cell, srcY: Float(row) * Rules.Cell,
76
+ tex: sheet,
77
+ x: x,
78
+ y: y,
79
+ w: size,
80
+ h: size,
77
- srcW: Rules.Cell, srcH: Rules.Cell, tint: Color.White)
81
+ srcX: Float(col) * Rules.Cell,
82
+ srcY: Float(row) * Rules.Cell,
83
+ srcW: Rules.Cell,
84
+ srcH: Rules.Cell,
85
+ tint: Color.White
86
+ )
78
87
 
79
88
  fun overlaps(ax: Float, ay: Float, ra: Float, bx: Float, by: Float, rb: Float) -> Bool =
80
89
  Math.hypot(ax - bx, ay - by) < ra + rb
@@ -122,9 +131,12 @@ fun flashPose(t: Float) -> AnimPose =
122
131
 
123
132
  # The game-over title: grows to 1.3x, then pulses between 1.17x and 1.3x.
124
133
  fun titleAnim() -> Anim =
134
+ Seq(
125
- Seq(steps: List(
135
+ steps: List(
126
- ScaleTo(sx: 1.3, sy: 1.3, secs: 0.3),
136
+ ScaleTo(sx: 1.3, sy: 1.3, secs: 0.3),
127
- Forever(anim: Seq(steps: List(ScaleTo(sx: 0.9, sy: 0.9, secs: 0.5), ScaleTo(sx: 1.0 / 0.9, sy: 1.0 / 0.9, secs: 0.5))))))
137
+ Forever(anim: Seq(steps: List(ScaleTo(sx: 0.9, sy: 0.9, secs: 0.5), ScaleTo(sx: 1.0 / 0.9, sy: 1.0 / 0.9, secs: 0.5))))
138
+ )
139
+ )
128
140
 
129
141
  fun titlePose(t: Float) -> AnimPose =
130
142
  Anim.at(titleAnim(), t)
@@ -150,9 +162,13 @@ record Ship(x: Float, y: Float, vx: Float, vy: Float, rotation: Float, hurt: Flo
150
162
  vx := if x != x0 then 0.0 else vx0
151
163
  vy := if y != y0 then 0.0 else vy0
152
164
  Ship(
165
+ x: x,
166
+ y: y,
153
- x: x, y: y, vx: vx, vy: vy,
167
+ vx: vx,
168
+ vy: vy,
154
169
  rotation: if vx * vx + vy * vy > 400.0 then Math.atan2(vx, 0.0 - vy) else self.rotation,
155
- hurt: {self.hurt - dt}.max(0.0))
170
+ hurt: {self.hurt - dt}.max(0.0)
171
+ )
156
172
 
157
173
  fun touches(self, at: Motion, radius: Float) -> Bool =
158
174
  overlaps(self.x, self.y, Rules.ShipRadius, at.x, at.y, radius)
@@ -167,7 +183,9 @@ record Ship(x: Float, y: Float, vx: Float, vy: Float, rotation: Float, hurt: Flo
167
183
  transform: Compose(first: Translate(dx: self.x, dy: self.y), then: Rotate(radians: self.rotation)),
168
184
  children: List(
169
185
  CircleShape(cx: 0.0, cy: 0.0, r: 17.0, color: Color(r: 0.4, g: 0.8, b: 1.0, a: 0.12)),
170
- cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(clock, 4, 14.0), 0)))
186
+ cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(clock, 4, 14.0), 0)
187
+ )
188
+ )
171
189
 
172
190
  # A glowing orb drifting about. `phase` offsets its animation from the other orbs.
173
191
  record Orb(id: Int, at: Motion, phase: Float, clock: Float) =
@@ -188,12 +206,16 @@ record Orb(id: Int, at: Motion, phase: Float, clock: Float) =
188
206
  transform: Identity,
189
207
  children: List(
190
208
  CircleShape(cx: 0.0, cy: 0.0, r: 26.0, color: Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5)),
191
- CircleShape(cx: 0.0, cy: 0.0, r: 17.0, color: Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5))))
209
+ CircleShape(cx: 0.0, cy: 0.0, r: 17.0, color: Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5))
210
+ )
211
+ )
192
212
  Group(
193
213
  transform: Translate(dx: self.at.x, dy: self.at.y),
194
214
  children: List(
195
215
  Filter(effect: BlendFx(mode: Blend.Add), child: glow, target: 0 - 1),
196
- cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(self.clock + self.phase, 4, 8.0), 1)))
216
+ cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(self.clock + self.phase, 4, 8.0), 1)
217
+ )
218
+ )
197
219
 
198
220
  # A spinning mine drifting about.
199
221
  record Mine(id: Int, at: Motion, rotation: Float, spin: Float, clock: Float) =
@@ -211,7 +233,8 @@ record Mine(id: Int, at: Motion, rotation: Float, spin: Float, clock: Float) =
211
233
  fun render(self, sheet: Int) -> SceneNode =
212
234
  Group(
213
235
  transform: Compose(first: Translate(dx: self.at.x, dy: self.at.y), then: Rotate(radians: self.rotation)),
214
- children: List(cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(self.clock, 4, 4.0), 2)))
236
+ children: List(cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(self.clock, 4, 4.0), 2))
237
+ )
215
238
 
216
239
  # A twinkling background star; it never moves.
217
240
  record Star(i: Int) =
@@ -252,14 +275,31 @@ record State(
252
275
  sw: Float,
253
276
  sh: Float,
254
277
  sheet: Int,
255
- target: Int) =
278
+ target: Int
279
+ ) =
256
280
 
257
281
  # A fresh run: the orbs and the mines spread over the arena away from the ship.
258
282
  fun start(seed: Int, best: Int, sheet: Int, target: Int, sw: Float, sh: Float) -> State =
259
283
  State(
284
+ ship: Ship.start(),
285
+ orbs: Nil,
286
+ mines: Nil,
287
+ stars: Star.field(0),
260
- ship: Ship.start(), orbs: Nil, mines: Nil, stars: Star.field(0), rng: Random.seeded(seed), score: 0, best: best,
288
+ rng: Random.seeded(seed),
289
+ score: 0,
290
+ best: best,
261
- lives: Rules.StartLives, over: false, clock: 0.0, overClock: 0.0, hurtAt: -1.0, overAt: -1.0, ui: Ui.init(),
291
+ lives: Rules.StartLives,
292
+ over: false,
293
+ clock: 0.0,
294
+ overClock: 0.0,
295
+ hurtAt: -1.0,
296
+ overAt: -1.0,
297
+ ui: Ui.init(),
298
+ sw: sw,
299
+ sh: sh,
300
+ sheet: sheet,
301
+ target: target
262
- sw: sw, sh: sh, sheet: sheet, target: target).fillOrbs(0).growMines()
302
+ ).fillOrbs(0).growMines()
263
303
 
264
304
  # --- update ---
265
305
 
@@ -294,8 +334,11 @@ record State(
294
334
  fun respawnOrb(self, o: Orb) -> State =
295
335
  sp := spawn(self.rng, self.ship.x, self.ship.y, 120.0, 40.0)
296
336
  State(
337
+ ..self,
297
- ..self, orbs: self.orbs.map(|x| if x.id == o.id then x.moved(sp.at) else x), rng: sp.rng,
338
+ orbs: self.orbs.map(|x| if x.id == o.id then x.moved(sp.at) else x),
339
+ rng: sp.rng,
298
- score: self.score + Rules.OrbPoints)
340
+ score: self.score + Rules.OrbPoints
341
+ )
299
342
 
300
343
  # Orbs `i` and up to OrbCount, each at a random spot away from the ship.
301
344
  fun fillOrbs(self, i: Int) -> State =
@@ -304,8 +347,10 @@ record State(
304
347
  fun addOrb(self, i: Int) -> State =
305
348
  sp := spawn(self.rng, self.ship.x, self.ship.y, 120.0, 40.0)
306
349
  State(
350
+ ..self,
307
- ..self, orbs: self.orbs.append(List(Orb(id: Id.Orb + i, at: sp.at, phase: Float(i) * 0.1, clock: 0.0))),
351
+ orbs: self.orbs.append(List(Orb(id: Id.Orb + i, at: sp.at, phase: Float(i) * 0.1, clock: 0.0))),
308
- rng: sp.rng)
352
+ rng: sp.rng
353
+ )
309
354
 
310
355
  # Mines are added (at the end) until there are as many as the score asks for.
311
356
  fun growMines(self) -> State =
@@ -316,7 +361,8 @@ record State(
316
361
  State(
317
362
  ..self,
318
363
  mines: self.mines.append(List(Mine(id: Id.Mine + self.mines.length(), at: sp.at, rotation: 0.0, spin: 2.0, clock: 0.0))),
319
- rng: sp.rng)
364
+ rng: sp.rng
365
+ )
320
366
 
321
367
  fun touchesMine(self) -> Bool =
322
368
  self.mines.fold(false, |hit, k| hit || self.ship.touches(k.at, Rules.MineRadius))
@@ -330,8 +376,14 @@ record State(
330
376
  done := lives <= 0
331
377
  ship := self.ship
332
378
  State(
379
+ ..self,
380
+ lives: lives,
333
- ..self, lives: lives, ship: Ship(..ship, hurt: Rules.HurtTime), hurtAt: self.clock, over: done,
381
+ ship: Ship(..ship, hurt: Rules.HurtTime),
382
+ hurtAt: self.clock,
383
+ over: done,
384
+ overAt: if done then self.clock else self.overAt,
334
- overAt: if done then self.clock else self.overAt, best: if done then self.best.max(self.score) else self.best)
385
+ best: if done then self.best.max(self.score) else self.best
386
+ )
335
387
 
336
388
  # After game over the orbs and mines stop: they only animate.
337
389
  fun settle(self) -> State =
@@ -375,7 +427,9 @@ record State(
375
427
  Group(transform: Identity, children: self.orbs.map(|o| o.render(self.sheet))),
376
428
  self.renderShip(),
377
429
  self.renderHud(),
378
- self.renderOverlay()))
430
+ self.renderOverlay()
431
+ )
432
+ )
379
433
 
380
434
  # The ship is gone once the game is over (an empty group keeps the slots).
381
435
  fun renderShip(self) -> SceneNode =
@@ -393,13 +447,16 @@ record State(
393
447
  transform: Identity,
394
448
  children: Cons(
395
449
  head: Text(text: "Score {self.score}", x: 16.0, y: 12.0, size: 28.0, color: Color.White),
396
- tail: Cons(head: Text(text: "Best {self.best.max(self.score)}", x: 16.0, y: 44.0, size: 20.0, color: Color.Yellow), tail: self.lifeIcons(0))))
450
+ tail: Cons(head: Text(text: "Best {self.best.max(self.score)}", x: 16.0, y: 44.0, size: 20.0, color: Color.Yellow), tail: self.lifeIcons(0))
451
+ )
452
+ )
397
453
 
398
454
  fun renderTitle(self) -> SceneNode =
399
455
  p := titlePose(self.clock - self.overAt)
400
456
  Group(
401
457
  transform: Compose(first: Translate(dx: 400.0 + p.dx, dy: 268.0 + p.dy), then: Scale(sx: p.scaleX, sy: p.scaleY)),
402
- children: List(Text(text: "Game over", x: -100.0, y: -24.0, size: 48.0, color: Color.Red)))
458
+ children: List(Text(text: "Game over", x: -100.0, y: -24.0, size: 48.0, color: Color.Red))
459
+ )
403
460
 
404
461
  # The game-over panel is empty while playing, so nothing in it can be hit.
405
462
  fun renderOverlay(self) -> SceneNode =
@@ -414,7 +471,9 @@ record State(
414
471
  RectShape(x: 0.0, y: 230.0, w: Rules.ArenaW, h: 170.0, color: Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)),
415
472
  self.renderTitle(),
416
473
  Text(text: "Press Enter to play again", x: 270.0, y: 300.0, size: 24.0, color: Color.White),
417
- button))
474
+ button
475
+ )
476
+ )
418
477
  Filter(effect: Alpha(alpha: {self.overClock * 2.0}.min(1.0)), child: panel, target: self.target)
419
478
 
420
479
  # True when (x, y) in screen pixels is on the restart button of the picture `render` builds.
@@ -444,8 +503,13 @@ fun startMusic(io: Io, w: World) =
444
503
  export fun init(io: Io) -> World =
445
504
  s := State.start(io.randomSeed(), 0, io.textureLoad("sprites.png"), io.textureCreate(1600, 1200), Rules.ArenaW, Rules.ArenaH)
446
505
  w := World(
506
+ state: s,
507
+ wasDown: false,
447
- state: s, wasDown: false, pickupSound: io.soundLoad("pickup.wav"), hitSound: io.soundLoad("hit.wav"),
508
+ pickupSound: io.soundLoad("pickup.wav"),
509
+ hitSound: io.soundLoad("hit.wav"),
448
- overSound: io.soundLoad("gameover.wav"), musicSound: io.soundLoad("music.wav"))
510
+ overSound: io.soundLoad("gameover.wav"),
511
+ musicSound: io.soundLoad("music.wav")
512
+ )
449
513
  startMusic(io, w)
450
514
  w
451
515
 
plum-tooling/tree-sitter-plum/queries/plum/format.scm CHANGED
@@ -401,3 +401,55 @@
401
401
  (trait
402
402
  (comment) @allow_blank_line_before)
403
403
 
404
+
405
+ ; ============================================================
406
+ ; Constructor arguments — a `Type(..)` call written across lines puts each
407
+ ; argument on its own indented line and the `)` on its own line; one written
408
+ ; on a single line stays on it. (Softlines break only when the argument list
409
+ ; already spans lines in the source.)
410
+ ; ============================================================
411
+
412
+ (class_argument_list
413
+ "(" @append_empty_softline @append_indent_start)
414
+
415
+ (class_argument_list
416
+ ")" @prepend_empty_softline @prepend_indent_end)
417
+
418
+ (class_argument_list
419
+ "," @append_spaced_softline)
420
+
421
+ ; Function-call arguments follow the same rule: `List(\n a,\n b\n)`.
422
+ (fn_argument_list
423
+ "(" @append_empty_softline @append_indent_start)
424
+
425
+ (fn_argument_list
426
+ ")" @prepend_empty_softline @prepend_indent_end)
427
+
428
+ (fn_argument_list
429
+ "," @append_spaced_softline)
430
+
431
+ ; `struct`/`record` field lists have no node of their own (the `(`, fields and
432
+ ; `)` sit directly under the type, whose body always spans lines), so a scope
433
+ ; from `(` to `)` decides: fields written across lines go one per line.
434
+ (struct
435
+ "(" @append_begin_scope @append_empty_scoped_softline @append_indent_start
436
+ ")" @prepend_end_scope @prepend_empty_scoped_softline @prepend_indent_end
437
+ (#scope_id! "ctor_fields"))
438
+
439
+ (record
440
+ "(" @append_begin_scope @append_empty_scoped_softline @append_indent_start
441
+ ")" @prepend_end_scope @prepend_empty_scoped_softline @prepend_indent_end
442
+ (#scope_id! "ctor_fields"))
443
+
444
+ (struct
445
+ "," @append_spaced_scoped_softline
446
+ (#scope_id! "ctor_fields"))
447
+
448
+ (record
449
+ "," @append_spaced_scoped_softline
450
+ (#scope_id! "ctor_fields"))
451
+
452
+ ; `mut x: T` fields and `export fun`.
453
+ "mut" @append_space
454
+
455
+ (fn "export" @append_space)