plum

#treesitter#compiler#wasm

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

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


818e0d0 — Peter John 2026-11-02T19:44:08+05:30
T7: editor support
Files changed (2) hide show
  1. plum-lsp/src/completion.rs +55 -13
  2. plum-lsp/src/main.rs +272 -1
plum-lsp/src/completion.rs CHANGED
@@ -341,21 +341,15 @@ fn walkStmt(
341
341
  }
342
342
  }
343
343
 
344
- /// Completion items for `receiver`'s methods/fields, given the function the
344
+ /// Best-effort type of `receiver` (`self`, an int literal, or a local) inside `cursor_fn`.
345
- /// cursor is inside (`None` if the cursor isn't inside any function — e.g. a
346
- /// top-level `test` body isn't covered, see this module's doc comment).
347
- pub fn memberItems(
345
+ pub fn receiverType(
348
346
  cursor_fn: Option<&ast::Fn>,
349
347
  receiver: &str,
350
348
  global_env: &TypeEnv,
351
349
  ctx: &CheckCtx,
352
- stars: &[ast::Fn],
353
- ) -> Vec<CompletionItem> {
350
+ ) -> Option<PlumType> {
354
- let Some(f) = cursor_fn else {
351
+ let f = cursor_fn?;
355
- return Vec::new();
356
- };
357
-
358
- let receiver_ty = if receiver == "self" {
352
+ if receiver == "self" {
359
353
  f.type_param.as_ref().map(|r| {
360
354
  plumTypeFromAst(&ast::Type {
361
355
  name: r.clone(),
@@ -367,9 +361,57 @@ pub fn memberItems(
367
361
  Some(PlumType::TInt)
368
362
  } else {
369
363
  localVarTypes(f, global_env, ctx).get(receiver).cloned()
364
+ }
365
+ }
366
+
367
+ /// Fields and methods of trait `t` (no hidden `__get_`/`__set_` accessors).
368
+ pub fn traitItems(t: &ast::TraitDecl) -> Vec<CompletionItem> {
369
+ let mut items: Vec<CompletionItem> = t
370
+ .fields
371
+ .iter()
372
+ .map(|f| CompletionItem {
373
+ label: f.name.clone(),
374
+ kind: Some(CompletionItemKind::FIELD),
375
+ detail: Some(typeText(&f.ty)),
376
+ ..Default::default()
377
+ })
378
+ .collect();
379
+ for m in t.methods.iter().chain(t.required.iter()) {
380
+ if m.has_self && !items.iter().any(|i| i.label == m.name) {
381
+ items.push(CompletionItem {
382
+ label: m.name.clone(),
383
+ kind: Some(CompletionItemKind::METHOD),
384
+ ..Default::default()
370
- };
385
+ });
386
+ }
387
+ }
388
+ items
389
+ }
371
390
 
391
+ /// Source-like text of a type: `Name` or `Name[A, B]`.
392
+ pub fn typeText(t: &ast::Type) -> String {
393
+ if t.generics.is_empty() {
394
+ t.name.clone()
395
+ } else {
396
+ format!(
397
+ "{}[{}]",
398
+ t.name,
399
+ t.generics.iter().map(typeText).collect::<Vec<_>>().join(", ")
400
+ )
401
+ }
402
+ }
403
+
404
+ /// Completion items for `receiver`'s methods/fields, given the function the
405
+ /// cursor is inside (`None` if the cursor isn't inside any function — e.g. a
406
+ /// top-level `test` body isn't covered, see this module's doc comment).
407
+ pub fn memberItems(
408
+ cursor_fn: Option<&ast::Fn>,
409
+ receiver: &str,
410
+ global_env: &TypeEnv,
411
+ ctx: &CheckCtx,
412
+ stars: &[ast::Fn],
413
+ ) -> Vec<CompletionItem> {
372
- let Some(ty) = receiver_ty else {
414
+ let Some(ty) = receiverType(cursor_fn, receiver, global_env, ctx) else {
373
415
  return Vec::new();
374
416
  };
375
417
  let Some(recv_name) = methodReceiverName(&ty) else {
plum-lsp/src/main.rs CHANGED
@@ -33,6 +33,8 @@ use tower_lsp::{Client, LanguageServer, LspService, Server};
33
33
 
34
34
  mod completion;
35
35
 
36
+ use plum_core::ast;
37
+
36
38
  struct Backend {
37
39
  client: Client,
38
40
  docs: Mutex<HashMap<Url, String>>,
@@ -199,6 +201,8 @@ impl LanguageServer for Backend {
199
201
  trigger_characters: Some(vec![".".to_string()]),
200
202
  ..Default::default()
201
203
  }),
204
+ hover_provider: Some(HoverProviderCapability::Simple(true)),
205
+ definition_provider: Some(OneOf::Left(true)),
202
206
  ..Default::default()
203
207
  },
204
208
  server_info: Some(ServerInfo {
@@ -244,6 +248,45 @@ impl LanguageServer for Backend {
244
248
  self.docs.lock().await.remove(&params.text_document.uri);
245
249
  }
246
250
 
251
+ async fn hover(&self, params: HoverParams) -> RpcResult<Option<Hover>> {
252
+ let p = params.text_document_position_params;
253
+ let Ok(path) = p.text_document.uri.to_file_path() else {
254
+ return Ok(None);
255
+ };
256
+ let Some(text) = self.docs.lock().await.get(&p.text_document.uri).cloned() else {
257
+ return Ok(None);
258
+ };
259
+ Ok(hoverAt(&path, &self.libPath, &text, p.position.line as usize, p.position.character as usize)
260
+ .map(|s| Hover {
261
+ contents: HoverContents::Scalar(MarkedString::String(s)),
262
+ range: None,
263
+ }))
264
+ }
265
+
266
+ async fn goto_definition(
267
+ &self,
268
+ params: GotoDefinitionParams,
269
+ ) -> RpcResult<Option<GotoDefinitionResponse>> {
270
+ let p = params.text_document_position_params;
271
+ let Ok(path) = p.text_document.uri.to_file_path() else {
272
+ return Ok(None);
273
+ };
274
+ let Some(text) = self.docs.lock().await.get(&p.text_document.uri).cloned() else {
275
+ return Ok(None);
276
+ };
277
+ let Some((file, l, c)) = definitionAt(&path, &self.libPath, &text, p.position.line as usize, p.position.character as usize) else {
278
+ return Ok(None);
279
+ };
280
+ let Ok(uri) = Url::from_file_path(&file) else {
281
+ return Ok(None);
282
+ };
283
+ let pos = Position::new(l, c);
284
+ Ok(Some(GotoDefinitionResponse::Scalar(Location {
285
+ uri,
286
+ range: Range { start: pos, end: pos },
287
+ })))
288
+ }
289
+
247
290
  async fn completion(&self, params: CompletionParams) -> RpcResult<Option<CompletionResponse>> {
248
291
  let uri = params.text_document_position.text_document.uri;
249
292
  let pos = params.text_document_position.position;
@@ -328,7 +371,186 @@ fn memberCompletionFor(
328
371
  statics: Some(&statics),
329
372
  };
330
373
  let cursor_fn = completion::enclosingFn(&own_source, cursor_line);
374
+ if let Some(ty) = completion::receiverType(cursor_fn, receiver, &global_env, &ctx) {
375
+ if let Some(name) = plum_checker::methodReceiverName(&ty) {
376
+ if let Some(t) = merged.traits.iter().find(|t| t.name == name) {
377
+ return completion::traitItems(t);
378
+ }
379
+ }
380
+ }
381
+ let mut items =
331
- completion::memberItems(cursor_fn, receiver, &global_env, &ctx, &merged.stars)
382
+ completion::memberItems(cursor_fn, receiver, &global_env, &ctx, &merged.stars);
383
+ items.retain(|i| !i.label.starts_with("__get_") && !i.label.starts_with("__set_"));
384
+ items
385
+ }
386
+
387
+ /// What the cursor is on, for hover / go-to-definition.
388
+ enum Target {
389
+ /// `recv.field` where `field` comes from `owner` (a trait).
390
+ Field(ast::TraitDecl, String, ast::Type),
391
+ Trait(ast::TraitDecl),
392
+ }
393
+
394
+ fn identAt(line: &str, col: usize) -> Option<(usize, usize)> {
395
+ let b = line.as_bytes();
396
+ let isId = |c: u8| c.is_ascii_alphanumeric() || c == b'_';
397
+ let mut col = col.min(b.len());
398
+ if col == b.len() || !isId(b[col]) {
399
+ if col > 0 && isId(b[col - 1]) {
400
+ col -= 1;
401
+ } else {
402
+ return None;
403
+ }
404
+ }
405
+ let mut s = col;
406
+ while s > 0 && isId(b[s - 1]) {
407
+ s -= 1;
408
+ }
409
+ let mut e = col;
410
+ while e < b.len() && isId(b[e]) {
411
+ e += 1;
412
+ }
413
+ Some((s, e))
414
+ }
415
+
416
+ fn resolveTarget(
417
+ path: &Path,
418
+ libPath: &Path,
419
+ text: &str,
420
+ line0: usize,
421
+ col: usize,
422
+ ) -> Option<Target> {
423
+ let lineText = text.lines().nth(line0)?;
424
+ let (s, e) = identAt(lineText, col)?;
425
+ let word = &lineText[s..e];
426
+ let canon = std::fs::canonicalize(path).unwrap_or(path.to_path_buf());
427
+ let mut overrides = HashMap::new();
428
+ overrides.insert(canon, text.to_string());
429
+ let merged = plum_core::loadAndMergeWithOverrides(path, libPath, &overrides).ok()?;
430
+ if s > 0 && lineText.as_bytes()[s - 1] == b'.' {
431
+ let before = &lineText[..s - 1];
432
+ let rs = before
433
+ .rfind(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
434
+ .map(|i| i + 1)
435
+ .unwrap_or(0);
436
+ let receiver = &before[rs..];
437
+ let tree = Backend::parseTree(text);
438
+ let own = plum_core::AstParser::new(text).parseSource(tree.root_node());
439
+ let (global_env, methods, enum_variants, enum_params) =
440
+ plum_checker::buildGlobalTables(&merged);
441
+ let min_required = plum_checker::buildMinRequiredArgs(&merged);
442
+ let primitive_generics = plum_checker::buildPrimitiveGenerics(&merged);
443
+ let statics = plum_checker::buildStaticMethods(&merged);
444
+ let ctx = plum_checker::CheckCtx {
445
+ methods: &methods,
446
+ enum_variants: &enum_variants,
447
+ enum_params: &enum_params,
448
+ min_required: &min_required,
449
+ primitive_generics: &primitive_generics,
450
+ statics: Some(&statics),
451
+ };
452
+ let f = completion::enclosingFn(&own, line0 + 1);
453
+ let ty = completion::receiverType(f, receiver, &global_env, &ctx)?;
454
+ let owner = plum_checker::methodReceiverName(&ty)?;
455
+ for t in &merged.traits {
456
+ let Some(field) = t.fields.iter().find(|x| x.name == word) else {
457
+ continue;
458
+ };
459
+ // The trait itself, or a record that is one of its union variants.
460
+ let embeds = t.name == owner
461
+ || merged.items.iter().any(|i| {
462
+ matches!(i, ast::Item::Enum(en) if en.isTrait && en.name == t.name
463
+ && en.variants.iter().any(|v| {
464
+ v.name == owner
465
+ || v.name == format!("{}__{}", t.name, owner)
466
+ || v.member.as_ref().is_some_and(|m| m.name == owner)
467
+ }))
468
+ });
469
+ if embeds {
470
+ return Some(Target::Field(t.clone(), word.to_string(), field.ty.clone()));
471
+ }
472
+ }
473
+ return None;
474
+ }
475
+ merged
476
+ .traits
477
+ .iter()
478
+ .find(|t| t.name == word)
479
+ .map(|t| Target::Trait(t.clone()))
480
+ }
481
+
482
+ fn hoverAt(path: &Path, libPath: &Path, text: &str, line0: usize, col: usize) -> Option<String> {
483
+ match resolveTarget(path, libPath, text, line0, col)? {
484
+ Target::Field(t, name, ty) => Some(format!(
485
+ "{}: {} (from {})",
486
+ name,
487
+ completion::typeText(&ty),
488
+ t.name
489
+ )),
490
+ Target::Trait(t) => Some(format!("trait {}", t.name)),
491
+ }
492
+ }
493
+
494
+ /// Locates `needle` (a trait name or one of its fields) in the trait's declaration.
495
+ fn definitionAt(
496
+ path: &Path,
497
+ libPath: &Path,
498
+ text: &str,
499
+ line0: usize,
500
+ col: usize,
501
+ ) -> Option<(PathBuf, u32, u32)> {
502
+ let target = resolveTarget(path, libPath, text, line0, col)?;
503
+ let (t, field) = match &target {
504
+ Target::Field(t, name, _) => (t, Some(name.as_str())),
505
+ Target::Trait(t) => (t, None),
506
+ };
507
+ let canon = std::fs::canonicalize(path).unwrap_or(path.to_path_buf());
508
+ let mut overrides = HashMap::new();
509
+ overrides.insert(canon.clone(), text.to_string());
510
+ let merged = plum_core::loadAndMergeWithOverrides(path, libPath, &overrides).ok()?;
511
+ let mut files: Vec<String> = vec![path.to_string_lossy().into_owned()];
512
+ for i in &merged.items {
513
+ if let ast::Item::Fn(f) = i {
514
+ if !files.contains(&f.file) {
515
+ files.push(f.file.clone());
516
+ }
517
+ }
518
+ }
519
+ let header = format!("trait {}", t.name);
520
+ for file in files {
521
+ let p = PathBuf::from(&file);
522
+ let content = if std::fs::canonicalize(&p).unwrap_or(p.clone()) == canon {
523
+ text.to_string()
524
+ } else {
525
+ match std::fs::read_to_string(&p) {
526
+ Ok(c) => c,
527
+ Err(_) => continue,
528
+ }
529
+ };
530
+ let lines: Vec<&str> = content.lines().collect();
531
+ let Some(ln) = lines.get(t.line.saturating_sub(1)) else {
532
+ continue;
533
+ };
534
+ let Some(hc) = ln.find(&header) else { continue };
535
+ let nameCol = hc + "trait ".len();
536
+ let Some(field) = field else {
537
+ return Some((p, t.line as u32 - 1, nameCol as u32));
538
+ };
539
+ for (off, l) in lines.iter().enumerate().skip(t.line - 1).take(40) {
540
+ let from = if off == t.line - 1 { nameCol + t.name.len() } else { 0 };
541
+ let mut at = from;
542
+ while let Some(i) = l[at..].find(field) {
543
+ let c = at + i;
544
+ let after = l[c + field.len()..].trim_start();
545
+ let boundary = c == 0 || !l.as_bytes()[c - 1].is_ascii_alphanumeric() && l.as_bytes()[c - 1] != b'_';
546
+ if boundary && after.starts_with(':') {
547
+ return Some((p, off as u32, c as u32));
548
+ }
549
+ at = c + field.len();
550
+ }
551
+ }
552
+ }
553
+ None
332
554
  }
333
555
 
334
556
  #[tokio::main]
@@ -366,6 +588,55 @@ mod tests {
366
588
  PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
367
589
  }
368
590
 
591
+ const TRAIT_SRC: &str = "module t\nimport std/Int\nimport std/Bool\n\nrecord Vec2(x: Int, y: Int)\n\ntrait Entity(pos: Vec2, hp: Int) =\n fun alive(self) -> Bool = self.hp > 0\n\nrecord House(Entity, rooms: Int)\n\nfun main() =\n h := House(pos: Vec2(x: 1, y: 2), hp: 5, rooms: 3)\n h.pos\n e: Entity = h\n e.zzzzzz\n";
592
+
593
+ fn traitFile(tag: &str) -> PathBuf {
594
+ let dir = std::env::temp_dir().join(format!("plum-lsp-trait-{}-{}", tag, std::process::id()));
595
+ std::fs::create_dir_all(&dir).unwrap();
596
+ let path = dir.join("t.plum");
597
+ std::fs::write(&path, TRAIT_SRC).unwrap();
598
+ path
599
+ }
600
+
601
+ #[test]
602
+ fn hover_embedded_field() {
603
+ let path = traitFile("hover");
604
+ // line 12 (0-based 11): ` h.pos`, cursor on `pos`.
605
+ let h = hoverAt(&path, &repoRoot(), TRAIT_SRC, 13, 5).expect("hover");
606
+ assert!(h.contains("pos: Vec2 (from Entity)"), "{h}");
607
+ }
608
+
609
+ #[test]
610
+ fn complete_trait_value() {
611
+ let path = traitFile("complete");
612
+ let items = memberCompletionFor(&path, &repoRoot(), TRAIT_SRC, 16, "e");
613
+ let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
614
+ for want in ["pos", "hp", "alive"] {
615
+ assert!(labels.contains(&want), "missing {want} in {labels:?}");
616
+ }
617
+ assert!(!labels.iter().any(|l| l.starts_with("__")), "{labels:?}");
618
+ let rec = memberCompletionFor(&path, &repoRoot(), TRAIT_SRC, 14, "h");
619
+ let rl: Vec<&str> = rec.iter().map(|i| i.label.as_str()).collect();
620
+ assert!(!rl.iter().any(|l| l.starts_with("__")), "{rl:?}");
621
+ }
622
+
623
+ #[test]
624
+ fn goto_embedded_field() {
625
+ let path = traitFile("goto");
626
+ let (f, l, c) = definitionAt(&path, &repoRoot(), TRAIT_SRC, 13, 5).expect("def");
627
+ assert_eq!(f, path);
628
+ assert_eq!((l, c), (6, 13), "pos in `trait Entity(pos: ...`");
629
+ }
630
+
631
+ #[test]
632
+ fn goto_trait_name() {
633
+ let path = traitFile("name");
634
+ // `record House(Entity, ...` is line index 7; `Entity` starts at col 13.
635
+ let (f, l, c) = definitionAt(&path, &repoRoot(), TRAIT_SRC, 9, 15).expect("def");
636
+ assert_eq!(f, path);
637
+ assert_eq!((l, c), (6, 6));
638
+ }
639
+
369
640
  /// The whole diagnostic path (`publishAll`: syntax diagnostics, then the
370
641
  /// two-pass checker) must find nothing wrong in the Seq/fun*/for example.
371
642
  #[test]