plum
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
- plum-lsp/src/completion.rs +55 -13
- 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
|
-
///
|
|
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
|
|
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
|
-
) ->
|
|
350
|
+
) -> Option<PlumType> {
|
|
354
|
-
let
|
|
351
|
+
let f = cursor_fn?;
|
|
355
|
-
return Vec::new();
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
|
|
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) =
|
|
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(¶ms.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
|
-
|
|
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]
|