plum
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,
|
|
76
|
+
tex: sheet,
|
|
77
|
+
x: x,
|
|
78
|
+
y: y,
|
|
79
|
+
w: size,
|
|
80
|
+
h: size,
|
|
77
|
-
|
|
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
|
-
|
|
135
|
+
steps: List(
|
|
126
|
-
|
|
136
|
+
ScaleTo(sx: 1.3, sy: 1.3, secs: 0.3),
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
+
rng: Random.seeded(seed),
|
|
289
|
+
score: 0,
|
|
290
|
+
best: best,
|
|
261
|
-
lives: Rules.StartLives,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
508
|
+
pickupSound: io.soundLoad("pickup.wav"),
|
|
509
|
+
hitSound: io.soundLoad("hit.wav"),
|
|
448
|
-
overSound: io.soundLoad("gameover.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)
|