plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
plum-lsp/src/completion.rs
// Completion support. Three tiers, matching what's cheaply available:
//
// 1. Keywords + top-level names (fn/enum/variant/trait/const, own file and
// already-imported ones) — pure AST, no type inference.
// 2. Member completion after `expr.` — needs to know `expr`'s type. There's
// no real "infer the type at this cursor" facility in `plum_checker`
// (it type-checks whole functions, not arbitrary positions), so this
// reconstructs a best-effort local variable environment by walking the
// enclosing function's OWN body top-to-bottom (see `localVarTypes`) —
// correct for the common case (a variable assigned once, in a straight
// line above the cursor), not control-flow- or shadowing-aware.
// 3. Std lib symbols not yet imported, offered with an `additionalTextEdit`
// that inserts the `import` line — built once per `lib_path` and cached
// by the caller (parsing every std file on every keystroke would be
// wasteful).
#![allow(non_snake_case)]
use std::collections::HashMap;
use std::path::Path;
use plum_checker::types::{PlumType, TypeEnv, TypeScheme};
use plum_checker::{inferExpr, methodReceiverName, plumTypeFromAst, CheckCtx};
use plum_core::ast;
use tower_lsp::lsp_types::*;
const KEYWORDS: &[&str] = &[
"fun", "enum", "trait", "import", "module", "extern", "if", "else", "while", "for", "match",
"case", "when", "break", "continue", "return", "todo", "assert", "test", "self", "True",
"False",
];
pub fn keywordItems() -> Vec<CompletionItem> {
KEYWORDS
.iter()
.map(|k| CompletionItem {
label: k.to_string(),
kind: Some(CompletionItemKind::KEYWORD),
..Default::default()
})
.collect()
}
/// Every top-level name `source` declares: free functions, enums (and their
/// bare variant names), traits, consts. Excludes methods (`type_param.is_some()`)
/// — those only make sense after a `.`, handled by `memberItems` instead.
pub fn topLevelItems(source: &ast::Source) -> Vec<CompletionItem> {
let mut items = Vec::new();
for item in &source.items {
match item {
ast::Item::Fn(f) if f.type_param.is_none() => {
items.push(CompletionItem {
label: f.name.clone(),
kind: Some(CompletionItemKind::FUNCTION),
detail: Some("fn".to_string()),
..Default::default()
});
}
ast::Item::Fn(_) => {}
ast::Item::Enum(e) => {
items.push(CompletionItem {
label: e.name.clone(),
kind: Some(CompletionItemKind::ENUM),
..Default::default()
});
for v in &e.variants {
items.push(CompletionItem {
label: v.name.clone(),
kind: Some(CompletionItemKind::ENUM_MEMBER),
detail: Some(format!("{}.{}", e.name, v.name)),
..Default::default()
});
}
}
ast::Item::Trait(t) => {
items.push(CompletionItem {
label: t.name.clone(),
kind: Some(CompletionItemKind::INTERFACE),
..Default::default()
});
}
ast::Item::Const(c) => {
items.push(CompletionItem {
label: c.name.clone(),
kind: Some(CompletionItemKind::CONSTANT),
..Default::default()
});
}
ast::Item::Test(_) => {}
}
}
items
}
/// Replaces the identifier run ending exactly at `pos` (possibly empty, e.g.
/// right after a bare `.`) with a placeholder identifier, so the document
/// parses cleanly for `mergedSource`'s sake — mid-edit, that run is
/// necessarily incomplete (`self.` or `self.ag` while typing `self.age`),
/// which `tree-sitter` turns into an ERROR node, which `loadAndMergeWithOverrides`
/// treats as a hard parse failure for the WHOLE file (see `parseFile`'s
/// `firstErrorNode` check) — without this, completion would fail on almost
/// every real keystroke, since that's exactly when it's requested.
pub fn sanitizeForParsing(text: &str, pos: Position) -> String {
let mut out = String::with_capacity(text.len() + 8);
for (i, line) in text.lines().enumerate() {
if i > 0 {
out.push('\n');
}
if i as u32 == pos.line {
let chars: Vec<char> = line.chars().collect();
let end = (pos.character as usize).min(chars.len());
let mut start = end;
while start > 0 && (chars[start - 1].is_alphanumeric() || chars[start - 1] == '_') {
start -= 1;
}
out.extend(&chars[..start]);
out.push_str("zzzzzz");
out.extend(&chars[end..]);
} else {
out.push_str(line);
}
}
out
}
/// If the cursor sits right after `<ident>.<partial>` (or `<ident>.`),
/// returns `<ident>`. `line` is just the cursor's own line text, `char_idx`
/// its column treated as a char index (not real UTF-16 offset math — fine
/// for the ASCII identifiers this looks for).
pub fn receiverBeforeDot(line: &str, char_idx: usize) -> Option<String> {
let chars: Vec<char> = line.chars().collect();
let mut i = char_idx.min(chars.len());
while i > 0 && (chars[i - 1].is_alphanumeric() || chars[i - 1] == '_') {
i -= 1;
}
if i == 0 || chars[i - 1] != '.' {
return None;
}
let dot = i - 1;
let mut j = dot;
while j > 0 && (chars[j - 1].is_alphanumeric() || chars[j - 1] == '_') {
j -= 1;
}
if j == dot {
return None;
}
Some(chars[j..dot].iter().collect())
}
/// The `Fn` item (own free functions AND methods) whose declaration is the
/// closest one at or before `cursor_line` (1-based, matching `ast::Fn::line`)
/// — a stand-in for "which function contains the cursor" since blocks don't
/// carry end positions to check containment properly.
pub fn enclosingFn(source: &ast::Source, cursor_line: usize) -> Option<&ast::Fn> {
source
.items
.iter()
.filter_map(|i| match i {
ast::Item::Fn(f) if f.line > 0 && f.line <= cursor_line => Some(f),
_ => None,
})
.max_by_key(|f| f.line)
}
/// Best-effort local variable types for `f`'s body, built by walking every
/// `Stmt::Assign` (at any nesting depth) top-to-bottom and re-inferring with
/// `inferExpr` each time — see this module's doc comment for the tradeoffs.
fn localVarTypes(f: &ast::Fn, global: &TypeEnv, ctx: &CheckCtx) -> HashMap<String, PlumType> {
let mut env = HashMap::new();
if let Some(recv) = &f.type_param {
env.insert(
"self".to_string(),
plumTypeFromAst(&ast::Type {
name: recv.clone(),
generics: vec![],
}),
);
}
for p in &f.params {
let ty = match &p.ty {
ast::ParamType::Type(t) => plumTypeFromAst(t),
ast::ParamType::Variadic(t) => PlumType::TVariadic(Box::new(plumTypeFromAst(t))),
ast::ParamType::Fn(params, ret) => PlumType::TFun(
params.iter().map(plumTypeFromAst).collect(),
Box::new(
ret.as_ref()
.map(|r| plumTypeFromAst(r))
.unwrap_or(PlumType::TUnit),
),
),
};
env.insert(p.name.clone(), ty);
}
if let ast::FnBody::Block(block) = &f.body {
walkBlock(block, &mut env, global, ctx);
}
env
}
fn walkBlock(
block: &ast::Block,
env: &mut HashMap<String, PlumType>,
global: &TypeEnv,
ctx: &CheckCtx,
) {
for stmt in &block.stmts {
walkStmt(stmt, env, global, ctx);
}
}
fn walkStmt(
stmt: &ast::Stmt,
env: &mut HashMap<String, PlumType>,
global: &TypeEnv,
ctx: &CheckCtx,
) {
match stmt {
ast::Stmt::Assign(a) => {
for (target, value) in a.targets.iter().zip(a.values.iter()) {
if let ast::AssignTarget::Var(name) = target {
let mut combined = global.clone();
for (k, v) in env.iter() {
combined.insert(k.clone(), TypeScheme::mono(v.clone()));
}
if let Ok(t) = inferExpr(value, &combined, ctx) {
env.insert(name.clone(), t);
}
}
}
}
ast::Stmt::If(if_) => {
walkBlock(&if_.body, env, global, ctx);
for ei in &if_.else_ifs {
walkBlock(&ei.body, env, global, ctx);
}
if let Some(b) = &if_.else_ {
walkBlock(b, env, global, ctx);
}
}
ast::Stmt::While(w) => walkBlock(&w.body, env, global, ctx),
ast::Stmt::For(f) => {
for v in &f.vars {
env.entry(v.clone()).or_insert(PlumType::TInt);
}
walkBlock(&f.body, env, global, ctx);
}
ast::Stmt::Match(m) => {
for case in &m.cases {
walkBlock(&case.body, env, global, ctx);
}
}
_ => {}
}
}
/// Completion items for `receiver`'s methods/fields, given the function the
/// cursor is inside (`None` if the cursor isn't inside any function — e.g. a
/// top-level `test` body isn't covered, see this module's doc comment).
pub fn memberItems(
cursor_fn: Option<&ast::Fn>,
receiver: &str,
global_env: &TypeEnv,
ctx: &CheckCtx,
) -> Vec<CompletionItem> {
let Some(f) = cursor_fn else {
return Vec::new();
};
let receiver_ty = if receiver == "self" {
f.type_param.as_ref().map(|r| {
plumTypeFromAst(&ast::Type {
name: r.clone(),
generics: vec![],
})
})
} else {
localVarTypes(f, global_env, ctx).get(receiver).cloned()
};
let Some(ty) = receiver_ty else {
return Vec::new();
};
let Some(recv_name) = methodReceiverName(&ty) else {
return Vec::new();
};
let mut items = Vec::new();
for ((r, name), fn_ty) in ctx.methods.iter() {
if r != &recv_name {
continue;
}
let detail = match fn_ty {
PlumType::TFun(params, ret) => Some(format!(
"({}) -> {:?}",
params
.iter()
.map(|p| format!("{p:?}"))
.collect::<Vec<_>>()
.join(", "),
ret
)),
other => Some(format!("{other:?}")),
};
items.push(CompletionItem {
label: name.clone(),
kind: Some(CompletionItemKind::METHOD),
detail,
..Default::default()
});
}
for info in ctx.enum_variants.values() {
if info.enum_name != recv_name {
continue;
}
for field_name in &info.field_names {
items.push(CompletionItem {
label: field_name.clone(),
kind: Some(CompletionItemKind::FIELD),
..Default::default()
});
}
}
items
}
/// Maps every top-level name declared anywhere under `<lib_path>/plum-std/*.plum`
/// to that file's `import` path (`Str.plum` -> `"std/Str"`) — built by parsing
/// each file standalone (no import resolution needed just to list names).
/// Meant to be computed once and cached by the caller.
pub fn stdLibIndex(lib_path: &Path) -> HashMap<String, String> {
let mut map = HashMap::new();
let std_dir = lib_path.join("plum-std");
let Ok(entries) = std::fs::read_dir(&std_dir) else {
return map;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("plum") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let mut parser = tree_sitter::Parser::new();
if parser
.set_language(&tree_sitter_plum::LANGUAGE.into())
.is_err()
{
continue;
}
let Some(tree) = parser.parse(&text, None) else {
continue;
};
let source = plum_core::AstParser::new(&text).parseSource(tree.root_node());
let import_path = format!("std/{stem}");
for item in &source.items {
let name = match item {
ast::Item::Fn(f) if f.type_param.is_none() => Some(f.name.clone()),
ast::Item::Enum(e) => {
map.entry(e.name.clone())
.or_insert_with(|| import_path.clone());
for v in &e.variants {
map.entry(v.name.clone())
.or_insert_with(|| import_path.clone());
}
None
}
ast::Item::Trait(t) => Some(t.name.clone()),
ast::Item::Const(c) => Some(c.name.clone()),
_ => None,
};
if let Some(name) = name {
map.entry(name).or_insert_with(|| import_path.clone());
}
}
}
map
}
/// A `TextEdit` inserting `import <import_path>` right after the last
/// existing `import` line (or after the `module` line, or at the very top of
/// the file if neither exists) — plain line scanning, since `ast::Import`
/// doesn't carry a position to look this up structurally.
pub fn importInsertionEdit(text: &str, import_path: &str) -> TextEdit {
let mut last_import_line: Option<usize> = None;
let mut module_line: Option<usize> = None;
for (i, line) in text.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("import ") {
last_import_line = Some(i);
} else if trimmed.starts_with("module ") {
module_line = Some(i);
}
}
let at_line = last_import_line.or(module_line).map(|l| l + 1).unwrap_or(0) as u32;
TextEdit {
range: Range {
start: Position::new(at_line, 0),
end: Position::new(at_line, 0),
},
new_text: format!("import {import_path}\n"),
}
}