mod navigation;
use lsp_server::{
Connection, ErrorCode, Message, Notification as ServerNotification, Request as ServerRequest,
Response,
};
use lsp_types::notification::{
DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument, DidSaveTextDocument, Exit,
Notification as LspNotification, PublishDiagnostics,
};
use lsp_types::request::{
Completion, Formatting, GotoDefinition, HoverRequest, Request as LspRequest,
};
use lsp_types::{
CompletionItem, CompletionItemKind, CompletionOptions, CompletionParams, CompletionTextEdit,
Diagnostic as LspDiagnostic, DiagnosticRelatedInformation, DiagnosticSeverity,
DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
DidSaveTextDocumentParams, DocumentFormattingParams, GotoDefinitionParams,
GotoDefinitionResponse, HoverParams, HoverProviderCapability, InitializeParams,
InitializeResult, Location, NumberOrString, OneOf, Position, PositionEncodingKind,
PublishDiagnosticsParams, Range, SaveOptions, ServerCapabilities, ServerInfo,
TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
TextDocumentSyncSaveOptions, TextEdit, Uri,
};
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
use std::error::Error;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
use tess::{
Diagnostic as TessDiagnostic, Severity, SourceFile, Span,
ast::{Declaration, NumericLiteral, Parameter, Program, TypeRef},
compile_project_with_dependency_graph_and_overrides, compile_source, format_source,
lexer::{Keyword, Token, TokenKind, lex},
parse,
};
type LspResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
#[derive(Debug)]
struct OpenDocument {
text: String,
version: i32,
}
pub fn run_stdio() -> LspResult<()> {
let (connection, io_threads) = Connection::stdio();
serve(&connection)?;
drop(connection);
io_threads.join()?;
Ok(())
}
fn serve(connection: &Connection) -> LspResult<()> {
let (initialize_id, initialize_params) = connection.initialize_start()?;
let _: InitializeParams = serde_json::from_value(initialize_params)?;
let capabilities = ServerCapabilities {
position_encoding: Some(PositionEncodingKind::UTF16),
text_document_sync: Some(TextDocumentSyncCapability::Options(
TextDocumentSyncOptions {
open_close: Some(true),
change: Some(TextDocumentSyncKind::FULL),
save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
include_text: Some(true),
})),
..TextDocumentSyncOptions::default()
},
)),
document_formatting_provider: Some(OneOf::Left(true)),
hover_provider: Some(HoverProviderCapability::Simple(true)),
definition_provider: Some(OneOf::Left(true)),
completion_provider: Some(CompletionOptions {
resolve_provider: Some(false),
trigger_characters: Some(vec![
".".to_owned(),
" ".to_owned(),
":".to_owned(),
"=".to_owned(),
"(".to_owned(),
",".to_owned(),
"\"".to_owned(),
"/".to_owned(),
]),
..CompletionOptions::default()
}),
..ServerCapabilities::default()
};
let initialize_result = InitializeResult {
capabilities,
server_info: Some(ServerInfo {
name: "Tess Language Server".to_owned(),
version: Some(env!("CARGO_PKG_VERSION").to_owned()),
}),
};
connection.initialize_finish(initialize_id, serde_json::to_value(initialize_result)?)?;
let mut documents = BTreeMap::new();
for message in &connection.receiver {
match message {
Message::Request(request) => {
if connection.handle_shutdown(&request)? {
break;
}
let method = request.method.clone();
if let Err(error) = handle_request(connection, request, &documents) {
eprintln!("tess lsp: request `{method}` failed: {error}");
}
}
Message::Notification(notification) => {
if notification.method == Exit::METHOD {
return Err("received `exit` before `shutdown`".into());
}
let method = notification.method.clone();
if let Err(error) = handle_notification(connection, notification, &mut documents) {
eprintln!("tess lsp: notification `{method}` failed: {error}");
}
}
Message::Response(_) => {}
}
}
Ok(())
}
fn handle_request(
connection: &Connection,
request: ServerRequest,
documents: &BTreeMap<String, OpenDocument>,
) -> LspResult<()> {
if request.method == Completion::METHOD {
return handle_completion_request(connection, request, documents);
}
if request.method == HoverRequest::METHOD {
return handle_hover_request(connection, request, documents);
}
if request.method == GotoDefinition::METHOD {
return handle_definition_request(connection, request, documents);
}
if request.method == Formatting::METHOD {
return handle_formatting_request(connection, request, documents);
}
let method = request.method.clone();
send_error(
connection,
request,
ErrorCode::MethodNotFound,
format!("unsupported request `{method}`"),
)
}
fn handle_formatting_request(
connection: &Connection,
request: ServerRequest,
documents: &BTreeMap<String, OpenDocument>,
) -> LspResult<()> {
let parameters: DocumentFormattingParams = match serde_json::from_value(request.params.clone())
{
Ok(parameters) => parameters,
Err(error) => {
return send_error(
connection,
request,
ErrorCode::InvalidParams,
format!("invalid formatting parameters: {error}"),
);
}
};
let uri = ¶meters.text_document.uri;
let Some(document) = documents.get(uri.as_str()) else {
return send_error(
connection,
request,
ErrorCode::InvalidParams,
format!("document `{}` is not open", uri.as_str()),
);
};
let source = SourceFile::new(uri.as_str().to_owned(), document.text.clone());
let Ok(formatted) = format_source(source) else {
connection.sender.send(Message::Response(Response::new_ok(
request.id,
Option::<Vec<TextEdit>>::None,
)))?;
return Ok(());
};
let edits = if formatted == document.text {
Vec::new()
} else {
vec![TextEdit::new(
LineIndex::new(&document.text).full_range(),
formatted,
)]
};
connection
.sender
.send(Message::Response(Response::new_ok(request.id, Some(edits))))?;
Ok(())
}
fn handle_completion_request(
connection: &Connection,
request: ServerRequest,
documents: &BTreeMap<String, OpenDocument>,
) -> LspResult<()> {
let parameters: CompletionParams = match serde_json::from_value(request.params.clone()) {
Ok(parameters) => parameters,
Err(error) => {
return send_error(
connection,
request,
ErrorCode::InvalidParams,
format!("invalid completion parameters: {error}"),
);
}
};
let uri = ¶meters.text_document_position.text_document.uri;
let Some(document) = documents.get(uri.as_str()) else {
return send_error(
connection,
request,
ErrorCode::InvalidParams,
format!("document `{}` is not open", uri.as_str()),
);
};
let position = parameters.text_document_position.position;
let Some(items) = completion_items(uri, document, position, documents) else {
return send_error(
connection,
request,
ErrorCode::InvalidParams,
format!(
"completion position {}:{} is outside document `{}`",
position.line,
position.character,
uri.as_str()
),
);
};
connection
.sender
.send(Message::Response(Response::new_ok(request.id, items)))?;
Ok(())
}
fn handle_hover_request(
connection: &Connection,
request: ServerRequest,
documents: &BTreeMap<String, OpenDocument>,
) -> LspResult<()> {
let parameters: HoverParams = match serde_json::from_value(request.params.clone()) {
Ok(parameters) => parameters,
Err(error) => {
return send_error(
connection,
request,
ErrorCode::InvalidParams,
format!("invalid hover parameters: {error}"),
);
}
};
let uri = ¶meters.text_document_position_params.text_document.uri;
let Some(document) = documents.get(uri.as_str()) else {
return send_error(
connection,
request,
ErrorCode::InvalidParams,
format!("document `{}` is not open", uri.as_str()),
);
};
let position = parameters.text_document_position_params.position;
let Some(cursor) = LineIndex::new(&document.text).offset(position) else {
return send_error(
connection,
request,
ErrorCode::InvalidParams,
format!(
"hover position {}:{} is outside document `{}`",
position.line,
position.character,
uri.as_str()
),
);
};
let hover = navigation::navigation_index(uri, document, documents)
.and_then(|index| index.hover(uri, cursor));
connection
.sender
.send(Message::Response(Response::new_ok(request.id, hover)))?;
Ok(())
}
fn handle_definition_request(
connection: &Connection,
request: ServerRequest,
documents: &BTreeMap<String, OpenDocument>,
) -> LspResult<()> {
let parameters: GotoDefinitionParams = match serde_json::from_value(request.params.clone()) {
Ok(parameters) => parameters,
Err(error) => {
return send_error(
connection,
request,
ErrorCode::InvalidParams,
format!("invalid definition parameters: {error}"),
);
}
};
let uri = ¶meters.text_document_position_params.text_document.uri;
let Some(document) = documents.get(uri.as_str()) else {
return send_error(
connection,
request,
ErrorCode::InvalidParams,
format!("document `{}` is not open", uri.as_str()),
);
};
let position = parameters.text_document_position_params.position;
let Some(cursor) = LineIndex::new(&document.text).offset(position) else {
return send_error(
connection,
request,
ErrorCode::InvalidParams,
format!(
"definition position {}:{} is outside document `{}`",
position.line,
position.character,
uri.as_str()
),
);
};
let definition = navigation::navigation_index(uri, document, documents)
.and_then(|index| index.definition(uri, cursor))
.map(GotoDefinitionResponse::Scalar);
connection
.sender
.send(Message::Response(Response::new_ok(request.id, definition)))?;
Ok(())
}
fn send_error(
connection: &Connection,
request: ServerRequest,
code: ErrorCode,
message: String,
) -> LspResult<()> {
connection.sender.send(Message::Response(Response::new_err(
request.id,
code as i32,
message,
)))?;
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum MemberReceiver {
Name(String),
DeriveCall(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum CompletionSite {
Member {
receiver: MemberReceiver,
prefix: String,
replace: Span,
},
Word {
prefix: String,
replace: Span,
line_start: usize,
line_tokens: Vec<Token>,
tokens: Vec<Token>,
},
ImportPath {
prefix: String,
replace: Span,
},
}
#[derive(Clone, Debug)]
struct NamedCompletion {
name: String,
detail: String,
}
#[derive(Clone, Debug)]
struct ScopedCompletion {
name: String,
ty: String,
detail: String,
}
#[derive(Clone, Debug)]
struct FieldCompletion {
name: String,
detail: String,
ty: TypeRef,
optional: bool,
domain_values: Vec<String>,
}
#[derive(Clone, Debug)]
struct EntityCompletion {
detail: String,
fields: Vec<FieldCompletion>,
}
#[derive(Clone, Debug)]
struct RuleCompletion {
name: String,
detail: String,
parameter_types: Vec<String>,
overrides: BTreeSet<String>,
}
#[derive(Clone, Debug)]
struct DeriveCompletion {
name: String,
detail: String,
parameter_types: Vec<String>,
return_type: TypeRef,
}
#[derive(Clone, Debug)]
struct DecisionCompletion {
name: String,
detail: String,
parameter_types: Vec<String>,
return_type: TypeRef,
}
#[derive(Clone, Debug)]
struct VariantCompletion {
name: String,
enum_name: String,
detail: String,
}
#[derive(Debug, Default)]
struct CompletionCatalog {
sources: BTreeMap<String, NamedCompletion>,
entities: BTreeMap<String, EntityCompletion>,
enums: BTreeMap<String, NamedCompletion>,
variants: BTreeMap<(String, String), VariantCompletion>,
derives: BTreeMap<String, DeriveCompletion>,
decisions: BTreeMap<String, DecisionCompletion>,
rules: BTreeMap<String, RuleCompletion>,
}
impl CompletionCatalog {
fn add_program(&mut self, program: &Program) {
for declaration in &program.declarations {
if declaration.name().value.starts_with("@pkg_") {
continue;
}
match declaration {
Declaration::Source(source) => {
let name = source.name.value.clone();
self.sources
.entry(name.clone())
.or_insert_with(|| NamedCompletion {
name,
detail: source_detail(source),
});
}
Declaration::Entity(entity) => {
let entity_name = entity.name.value.clone();
self.entities.entry(entity_name.clone()).or_insert_with(|| {
let fields = entity
.fields
.iter()
.map(|field| FieldCompletion {
name: field.name.value.clone(),
detail: field_detail(&entity_name, field),
ty: field.ty.clone(),
optional: field.optional,
domain_values: field.domain.as_ref().map_or_else(
Vec::new,
|domain| {
let mut values = domain
.values
.iter()
.filter_map(|value| {
program
.source
.text
.get(value.span.start..value.span.end)
.map(str::trim)
.map(str::to_owned)
})
.collect::<Vec<_>>();
values.sort();
values.dedup();
values
},
),
})
.collect();
EntityCompletion {
detail: format!("entity · {} fields", entity.fields.len()),
fields,
}
});
}
Declaration::Enum(enumeration) => {
let enum_name = enumeration.name.value.clone();
self.enums
.entry(enum_name.clone())
.or_insert_with(|| NamedCompletion {
name: enum_name.clone(),
detail: format!("enum · {} variants", enumeration.variants.len()),
});
for variant in &enumeration.variants {
let name = variant.value.clone();
self.variants
.entry((enum_name.clone(), name.clone()))
.or_insert_with(|| VariantCompletion {
name: name.clone(),
enum_name: enum_name.clone(),
detail: format!("enum variant · {enum_name}"),
});
}
}
Declaration::Derive(derive) => {
let name = derive.name.value.clone();
self.derives
.entry(name.clone())
.or_insert_with(|| DeriveCompletion {
name,
detail: callable_detail(
"derive",
&derive.parameters,
type_name(&derive.return_type.value),
),
parameter_types: derive
.parameters
.iter()
.map(|parameter| parameter.ty.value.clone())
.collect(),
return_type: derive.return_type.value.clone(),
});
}
Declaration::Decision(decision) => {
let name = decision.name.value.clone();
self.decisions
.entry(name.clone())
.or_insert_with(|| DecisionCompletion {
name,
detail: callable_detail(
"decision",
&decision.parameters,
type_name(&decision.return_type.value),
),
parameter_types: decision
.parameters
.iter()
.map(|parameter| parameter.ty.value.clone())
.collect(),
return_type: decision.return_type.value.clone(),
});
}
Declaration::Rule(rule) => {
let name = rule.name.value.clone();
self.rules
.entry(name.clone())
.or_insert_with(|| RuleCompletion {
name,
detail: format!("rule · {}", parameter_list(&rule.parameters)),
parameter_types: rule
.parameters
.iter()
.map(|parameter| parameter.ty.value.clone())
.collect(),
overrides: rule
.effects
.iter()
.filter_map(|effect| {
let tess::ast::Effect::Override { rule, .. } = effect else {
return None;
};
Some(rule.value.clone())
})
.collect(),
});
}
Declaration::State(_)
| Declaration::Action(_)
| Declaration::Transition(_)
| Declaration::Case(_)
| Declaration::Invariant(_)
| Declaration::Trace(_) => {}
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DeclarationKind {
Enum,
Entity,
Derive,
Decision,
Source,
Rule,
Case,
Invariant,
}
#[derive(Clone, Debug)]
struct LexicalDeclaration {
kind: DeclarationKind,
start: usize,
name: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum CompletionContext {
Module,
TopLevel {
allow_import: bool,
},
ParameterType {
scalar: bool,
},
ReturnType {
allow_entity: bool,
},
DecisionCardinality,
EntityFieldType,
EntityFieldModifier {
allow_optional: bool,
allow_range: bool,
allow_domain: bool,
},
SourceMetadata,
RuleClause {
allow_when: bool,
allow_then: bool,
allow_source: bool,
},
RuleThen,
RuleOverride,
RuleSource,
DecisionOperator {
allow_not_equal: bool,
},
CaseClause {
allow_let: bool,
},
CaseEntity,
CaseExpect,
CaseField {
entity: String,
used_fields: BTreeSet<String>,
},
CaseFieldValue {
entity: String,
field: String,
},
InvariantQuantifier,
InvariantQuantifierKind,
InvariantAssertion,
CardinalityOne,
CardinalityOrOne,
InvariantDecision,
InvariantThen,
Expression,
None,
}
#[derive(Clone, Copy)]
struct CompletionCandidateContext<'a> {
current: Option<&'a Program>,
declaration: Option<&'a LexicalDeclaration>,
text: &'a str,
cursor: usize,
line_tokens: &'a [Token],
all_tokens: &'a [Token],
}
#[derive(Clone, Debug)]
struct Candidate {
label: String,
kind: CompletionItemKind,
detail: String,
}
impl Candidate {
fn new(label: &str, kind: CompletionItemKind, detail: &str) -> Self {
Self {
label: label.to_owned(),
kind,
detail: detail.to_owned(),
}
}
}
fn completion_items(
uri: &Uri,
document: &OpenDocument,
position: Position,
documents: &BTreeMap<String, OpenDocument>,
) -> Option<Vec<CompletionItem>> {
let index = LineIndex::new(&document.text);
let cursor = index.offset(position)?;
let current = parse(SourceFile::new(
uri.as_str().to_owned(),
document.text.clone(),
))
.program;
let mut catalog = CompletionCatalog::default();
for program in completion_programs(uri, document, documents) {
catalog.add_program(&program);
}
if let Some(program) = ¤t {
catalog.add_program(program);
}
let Some(site) = completion_site(&document.text, cursor) else {
return Some(Vec::new());
};
let items = match site {
CompletionSite::Member {
receiver,
prefix,
replace,
} => {
let lexical = active_lexical_declaration(&document.text, cursor);
let entity_name = match &receiver {
MemberReceiver::Name(receiver) => {
if let Some(declaration) = lexical.as_ref() {
lexical_member_scope_active(&document.text, declaration, cursor)
.then(|| {
lexical_receiver_entity(
&document.text,
declaration,
cursor,
receiver,
)
})
.flatten()
} else {
current
.as_ref()
.and_then(|program| receiver_entity(program, cursor, receiver))
}
}
MemberReceiver::DeriveCall(derive) => lexical.as_ref().and_then(|declaration| {
lexical_member_scope_active(&document.text, declaration, cursor)
.then(|| catalog.derives.get(derive))
.flatten()
.and_then(|derive| match &derive.return_type {
TypeRef::Named(name) if catalog.entities.contains_key(name) => {
Some(name.clone())
}
_ => None,
})
}),
};
let Some(entity_name) = entity_name else {
return Some(Vec::new());
};
catalog
.entities
.get(&entity_name)
.map_or_else(Vec::new, |entity| {
entity
.fields
.iter()
.filter(|candidate| candidate.name.starts_with(&prefix))
.enumerate()
.map(|(order, candidate)| {
let mut item = completion_item(
&index,
replace,
&candidate.name,
CompletionItemKind::FIELD,
&candidate.detail,
);
item.sort_text = Some(format!("{order:04}"));
item
})
.collect()
})
}
CompletionSite::ImportPath { prefix, replace } => import_path_candidates(uri, &prefix)
.into_iter()
.enumerate()
.map(|(order, candidate)| {
let mut item = completion_item(
&index,
replace,
&candidate.label,
candidate.kind,
&candidate.detail,
);
item.sort_text = Some(format!("{order:04}"));
item
})
.collect(),
CompletionSite::Word {
prefix,
replace,
line_start,
line_tokens,
tokens,
} => {
let lexical = active_lexical_declaration(&document.text, cursor);
let context = completion_context(
&document.text,
cursor,
line_start,
replace.start,
&line_tokens,
&tokens,
lexical.as_ref(),
);
let candidate_context = CompletionCandidateContext {
current: current.as_ref(),
declaration: lexical.as_ref(),
text: &document.text,
cursor,
line_tokens: &line_tokens,
all_tokens: &tokens,
};
let candidates = candidates_for_context(context, &catalog, &candidate_context);
candidates
.into_iter()
.filter(|candidate| candidate.label.starts_with(&prefix))
.enumerate()
.map(|(order, candidate)| {
let mut item = completion_item(
&index,
replace,
&candidate.label,
candidate.kind,
&candidate.detail,
);
item.sort_text = Some(format!("{order:04}"));
item
})
.collect()
}
};
Some(items)
}
fn completion_item(
index: &LineIndex<'_>,
replace: Span,
label: &str,
kind: CompletionItemKind,
detail: &str,
) -> CompletionItem {
CompletionItem {
label: label.to_owned(),
kind: Some(kind),
detail: Some(detail.to_owned()),
filter_text: Some(label.to_owned()),
text_edit: Some(CompletionTextEdit::Edit(TextEdit::new(
index.range(replace),
label.to_owned(),
))),
..CompletionItem::default()
}
}
fn completion_site(text: &str, cursor: usize) -> Option<CompletionSite> {
let before = text.get(..cursor)?;
if let Some(site) = import_path_completion_site(before, cursor) {
return Some(site);
}
let lexed = lex(&SourceFile::new("<completion>", before.to_owned()));
if lexed
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "L0002" && diagnostic.primary.end == cursor)
{
return None;
}
if lexed.comments.last().is_some_and(|comment| {
!before[comment.span.end..].contains('\n') && !before[comment.span.end..].contains('\r')
}) {
return None;
}
let tokens: Vec<_> = lexed
.tokens
.iter()
.filter(|token| !matches!(token.kind, TokenKind::Newline | TokenKind::Eof))
.collect();
if let Some(site) = member_completion_site(text, cursor, &tokens) {
return Some(site);
}
let line_start = before.rfind(['\n', '\r']).map_or(0, |newline| newline + 1);
let mut line_tokens: Vec<Token> = lexed
.tokens
.iter()
.filter(|token| {
token.span.start >= line_start
&& !matches!(token.kind, TokenKind::Newline | TokenKind::Eof)
})
.cloned()
.collect();
let trailing_word = line_tokens.last().and_then(|token| {
if token.span.end == cursor {
completion_word(token).map(|prefix| (prefix.to_owned(), token.span))
} else {
None
}
});
let (prefix, replace) = if let Some((prefix, replace)) = trailing_word {
line_tokens.pop();
(prefix, replace)
} else {
let gap_start = line_tokens
.last()
.map_or(line_start, |token| token.span.end);
if !horizontal_whitespace(text.get(gap_start..cursor)?) {
return None;
}
(String::new(), Span::new(cursor, cursor))
};
Some(CompletionSite::Word {
prefix,
replace,
line_start,
line_tokens,
tokens: lexed.tokens,
})
}
fn import_path_completion_site(before: &str, cursor: usize) -> Option<CompletionSite> {
let line_start = before.rfind(['\n', '\r']).map_or(0, |newline| newline + 1);
let line = before.get(line_start..)?;
let leading = line.len() - line.trim_start_matches([' ', '\t']).len();
let declaration = line.get(leading..)?;
let rest = declaration.strip_prefix("import")?;
if !rest.starts_with([' ', '\t']) {
return None;
}
let whitespace = rest.len() - rest.trim_start_matches([' ', '\t']).len();
let quoted = rest.get(whitespace..)?.strip_prefix('"')?;
if quoted.contains(['"', '\\']) || quoted.starts_with('/') {
return None;
}
let replace_start = line_start + leading + "import".len() + whitespace + 1;
Some(CompletionSite::ImportPath {
prefix: quoted.to_owned(),
replace: Span::new(replace_start, cursor),
})
}
fn import_path_candidates(uri: &Uri, prefix: &str) -> Vec<Candidate> {
let Some(document) = file_uri_path(uri) else {
return Vec::new();
};
let Some(parent) = document.parent() else {
return Vec::new();
};
let (directory_prefix, leaf_prefix) = prefix.rfind('/').map_or(("", prefix), |slash| {
(&prefix[..=slash], &prefix[slash + 1..])
});
let Ok(entries) = fs::read_dir(parent.join(directory_prefix)) else {
return Vec::new();
};
let mut candidates = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let name = entry.file_name().into_string().ok()?;
if name.starts_with('.') || !name.starts_with(leaf_prefix) {
return None;
}
let file_type = entry.file_type().ok()?;
if file_type.is_dir() {
let label = format!("{directory_prefix}{name}/");
Some(Candidate::new(
&label,
CompletionItemKind::FOLDER,
"import directory",
))
} else if file_type.is_file()
&& entry
.path()
.extension()
.is_some_and(|extension| extension == "tes")
&& entry.path() != document
{
let label = format!("{directory_prefix}{name}");
Some(Candidate::new(
&label,
CompletionItemKind::FILE,
"Tess import file",
))
} else {
None
}
})
.collect::<Vec<_>>();
candidates.sort_by(|left, right| left.label.cmp(&right.label));
candidates
}
fn member_completion_site(text: &str, cursor: usize, tokens: &[&Token]) -> Option<CompletionSite> {
let (dot_index, dot, prefix, replace_start) = match tokens {
[.., dot] if matches!(dot.kind, TokenKind::Dot) => {
(tokens.len().checked_sub(1)?, *dot, "", cursor)
}
[.., dot, field]
if matches!(dot.kind, TokenKind::Dot)
&& identifier(field).is_some()
&& field.span.end == cursor =>
{
(
tokens.len().checked_sub(2)?,
*dot,
identifier(field)?,
field.span.start,
)
}
_ => return None,
};
let receiver_end = dot_index.checked_sub(1)?;
let receiver = if let Some(name) = identifier(tokens[receiver_end]) {
MemberReceiver::Name(name.to_owned())
} else if matches!(tokens[receiver_end].kind, TokenKind::RightParen) {
let mut depth = 0usize;
let mut open = None;
for index in (0..=receiver_end).rev() {
match tokens[index].kind {
TokenKind::RightParen => depth += 1,
TokenKind::LeftParen => {
depth = depth.saturating_sub(1);
if depth == 0 {
open = Some(index);
break;
}
}
_ => {}
}
}
let callee = open?
.checked_sub(1)
.and_then(|index| identifier(tokens[index]))?;
MemberReceiver::DeriveCall(callee.to_owned())
} else {
return None;
};
if dot.span.end > cursor
|| !horizontal_whitespace(&text[tokens[receiver_end].span.end..dot.span.start])
|| !horizontal_whitespace(&text[dot.span.end..replace_start])
{
return None;
}
Some(CompletionSite::Member {
receiver,
prefix: prefix.to_owned(),
replace: Span::new(replace_start, cursor),
})
}
fn identifier(token: &Token) -> Option<&str> {
let TokenKind::Identifier(value) = &token.kind else {
return None;
};
Some(value)
}
fn completion_word(token: &Token) -> Option<&str> {
match &token.kind {
TokenKind::Identifier(value) => Some(value),
TokenKind::Keyword(keyword) => Some(keyword.as_str()),
_ => None,
}
}
fn horizontal_whitespace(text: &str) -> bool {
text.chars()
.all(|character| matches!(character, ' ' | '\t' | '\u{000C}'))
}
fn completion_context(
text: &str,
cursor: usize,
line_start: usize,
word_start: usize,
line_tokens: &[Token],
tokens: &[Token],
declaration: Option<&LexicalDeclaration>,
) -> CompletionContext {
let first_start = line_tokens
.first()
.map_or(word_start, |token| token.span.start);
let indentation = text.get(line_start..first_start).unwrap_or_default();
if !horizontal_whitespace(indentation) {
return CompletionContext::None;
}
if let Some(declaration) = declaration {
let declaration_tokens =
significant_declaration_tokens(tokens, declaration.start, word_start);
if let Some(context) = continued_header_completion_context(declaration, &declaration_tokens)
{
return context;
}
if matches!(
declaration.kind,
DeclarationKind::Rule
| DeclarationKind::Case
| DeclarationKind::Invariant
| DeclarationKind::Derive
) && has_open_body_parenthesis(declaration, &declaration_tokens)
{
return CompletionContext::Expression;
}
if declaration.kind == DeclarationKind::Derive
&& derive_expression_line_active(text, declaration, cursor)
{
return CompletionContext::Expression;
}
if has_open_declaration_brace(&declaration_tokens) {
match declaration.kind {
DeclarationKind::Entity => {
if line_tokens
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::Colon))
{
return CompletionContext::EntityFieldType;
}
let context = entity_field_modifier_context(line_tokens);
if context != CompletionContext::None {
return context;
}
}
DeclarationKind::Source
if line_tokens.is_empty()
|| line_tokens.last().is_some_and(|token| {
matches!(token.kind, TokenKind::LeftBrace | TokenKind::Comma)
}) =>
{
return CompletionContext::SourceMetadata;
}
_ => {}
}
}
}
let top_level = indentation.is_empty();
if top_level {
if let Some(context) = header_completion_context(line_tokens) {
return context;
}
if line_tokens.is_empty() {
return if has_module_header(text, line_start) {
CompletionContext::TopLevel {
allow_import: declaration.is_none(),
}
} else {
CompletionContext::Module
};
}
return CompletionContext::None;
}
let Some(declaration) = declaration else {
return CompletionContext::None;
};
match declaration.kind {
DeclarationKind::Entity => {
if line_tokens
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::Colon))
{
CompletionContext::EntityFieldType
} else {
entity_field_modifier_context(line_tokens)
}
}
DeclarationKind::Source => {
if line_tokens.is_empty() {
CompletionContext::SourceMetadata
} else {
CompletionContext::None
}
}
DeclarationKind::Rule => {
rule_completion_context(line_tokens, tokens, declaration.start, line_start)
}
DeclarationKind::Case => {
if let Some(binding) = open_case_binding(tokens, declaration.start, cursor) {
if line_tokens.is_empty() {
CompletionContext::CaseField {
entity: binding.entity,
used_fields: binding.used_fields,
}
} else if let Some(colon) = line_tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::Colon))
{
line_tokens[..colon]
.iter()
.rev()
.find_map(identifier)
.map_or(CompletionContext::None, |field| {
CompletionContext::CaseFieldValue {
entity: binding.entity,
field: field.to_owned(),
}
})
} else {
CompletionContext::None
}
} else {
case_completion_context(line_tokens, tokens, declaration.start, line_start)
}
}
DeclarationKind::Invariant => {
invariant_completion_context(line_tokens, tokens, declaration.start, cursor)
}
DeclarationKind::Derive | DeclarationKind::Decision | DeclarationKind::Enum => {
CompletionContext::None
}
}
}
fn significant_declaration_tokens(tokens: &[Token], start: usize, before: usize) -> Vec<Token> {
tokens
.iter()
.filter(|token| {
token.span.start >= start
&& token.span.start < before
&& !matches!(token.kind, TokenKind::Newline | TokenKind::Eof)
})
.cloned()
.collect()
}
fn has_open_declaration_brace(tokens: &[Token]) -> bool {
let mut depth = 0usize;
for token in tokens {
match token.kind {
TokenKind::LeftBrace => depth += 1,
TokenKind::RightBrace => depth = depth.saturating_sub(1),
_ => {}
}
}
depth > 0
}
fn continued_header_completion_context(
declaration: &LexicalDeclaration,
tokens: &[Token],
) -> Option<CompletionContext> {
if !matches!(
declaration.kind,
DeclarationKind::Rule | DeclarationKind::Decision | DeclarationKind::Derive
) {
return None;
}
let open = tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::LeftParen))?;
if tokens[open + 1..]
.iter()
.any(|token| matches!(token.kind, TokenKind::RightParen))
{
return None;
}
Some(if parameter_type_position(tokens) {
CompletionContext::ParameterType {
scalar: declaration.kind == DeclarationKind::Derive,
}
} else {
CompletionContext::None
})
}
fn has_open_body_parenthesis(declaration: &LexicalDeclaration, tokens: &[Token]) -> bool {
let body_start = if matches!(
declaration.kind,
DeclarationKind::Rule | DeclarationKind::Decision | DeclarationKind::Derive
) {
let Some(close) = tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::RightParen))
else {
return false;
};
close + 1
} else {
0
};
let mut depth = 0usize;
for token in &tokens[body_start..] {
match token.kind {
TokenKind::LeftParen => depth += 1,
TokenKind::RightParen => depth = depth.saturating_sub(1),
_ => {}
}
}
depth > 0
}
fn header_completion_context(tokens: &[Token]) -> Option<CompletionContext> {
let declaration = declaration_keyword(tokens.first()?)?;
if matches!(
declaration,
DeclarationKind::Rule | DeclarationKind::Decision | DeclarationKind::Derive
) && parameter_type_position(tokens)
{
return Some(CompletionContext::ParameterType {
scalar: declaration == DeclarationKind::Derive,
});
}
if matches!(
declaration,
DeclarationKind::Decision | DeclarationKind::Derive
) && tokens
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::Arrow))
{
return Some(CompletionContext::ReturnType {
allow_entity: declaration == DeclarationKind::Derive,
});
}
if declaration == DeclarationKind::Derive
&& tokens
.iter()
.any(|token| matches!(token.kind, TokenKind::Colon))
{
return Some(CompletionContext::Expression);
}
if declaration == DeclarationKind::Decision {
let arrow = tokens
.iter()
.rposition(|token| matches!(token.kind, TokenKind::Arrow))?;
if tokens.len() == arrow + 2 {
return Some(CompletionContext::DecisionCardinality);
}
return match tokens.get(arrow + 2..).unwrap_or_default() {
[
Token {
kind: TokenKind::Keyword(Keyword::Cardinality),
..
},
] => Some(CompletionContext::DecisionCardinality),
[
Token {
kind: TokenKind::Keyword(Keyword::Exactly),
..
},
]
| [
Token {
kind: TokenKind::Keyword(Keyword::Zero),
..
},
Token {
kind: TokenKind::Keyword(Keyword::Or),
..
},
] => Some(CompletionContext::CardinalityOne),
[
Token {
kind: TokenKind::Keyword(Keyword::Zero),
..
},
] => Some(CompletionContext::CardinalityOrOne),
_ => None,
};
}
None
}
fn entity_field_modifier_context(tokens: &[Token]) -> CompletionContext {
let Some(colon) = tokens
.iter()
.rposition(|token| matches!(token.kind, TokenKind::Colon))
else {
return CompletionContext::None;
};
let Some(ty) = tokens.get(colon + 1).and_then(identifier) else {
return CompletionContext::None;
};
let numeric = matches!(ty, "Int" | "Decimal");
let domain = matches!(ty, "String" | "Decimal");
let suffix = &tokens[colon + 2..];
let optional_prefix = matches!(
suffix.first().map(|token| &token.kind),
Some(TokenKind::Keyword(Keyword::Optional) | TokenKind::Question)
);
let modifiers = if optional_prefix {
&suffix[1..]
} else {
suffix
};
if modifiers.is_empty() {
return CompletionContext::EntityFieldModifier {
allow_optional: !optional_prefix,
allow_range: numeric,
allow_domain: domain,
};
}
if numeric && complete_range_modifier(modifiers) {
return CompletionContext::EntityFieldModifier {
allow_optional: !optional_prefix,
allow_range: false,
allow_domain: false,
};
}
CompletionContext::None
}
fn complete_range_modifier(tokens: &[Token]) -> bool {
if !matches!(
tokens.first().map(|token| &token.kind),
Some(TokenKind::Keyword(Keyword::Range))
) {
return false;
}
let mut index = 1;
for bound in 0..2 {
if tokens
.get(index)
.is_some_and(|token| matches!(token.kind, TokenKind::Minus))
{
index += 1;
}
if !tokens
.get(index)
.is_some_and(|token| matches!(token.kind, TokenKind::Number(_)))
{
return false;
}
index += 1;
if bound == 0 {
if !tokens
.get(index)
.is_some_and(|token| matches!(token.kind, TokenKind::Range))
{
return false;
}
index += 1;
}
}
index == tokens.len()
}
fn parameter_type_position(tokens: &[Token]) -> bool {
let Some(open) = tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::LeftParen))
else {
return false;
};
if tokens[open + 1..]
.iter()
.any(|token| matches!(token.kind, TokenKind::RightParen))
{
return false;
}
tokens.len() == open + 1
|| tokens
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::Comma))
}
fn rule_completion_context(
line_tokens: &[Token],
tokens: &[Token],
declaration_start: usize,
line_start: usize,
) -> CompletionContext {
let Some(first) = line_tokens.first() else {
let before_line = tokens
.iter()
.filter(|token| token.span.start >= declaration_start && token.span.start < line_start);
let mut condition_seen = false;
let mut effect_seen = false;
let mut source_seen = false;
for token in before_line {
match token.kind {
TokenKind::Keyword(Keyword::When) => condition_seen = true,
TokenKind::Keyword(Keyword::Then) => effect_seen = true,
TokenKind::Keyword(Keyword::Source) => source_seen = true,
_ => {}
}
}
return CompletionContext::RuleClause {
allow_when: !condition_seen,
allow_then: condition_seen && !source_seen,
allow_source: effect_seen,
};
};
match first.kind {
TokenKind::Keyword(Keyword::When) => CompletionContext::Expression,
TokenKind::Keyword(Keyword::Source) => CompletionContext::RuleSource,
TokenKind::Keyword(Keyword::Then) => match line_tokens.get(1).map(|token| &token.kind) {
None => CompletionContext::RuleThen,
Some(TokenKind::Keyword(Keyword::Override)) if line_tokens.len() == 2 => {
CompletionContext::RuleOverride
}
Some(_) if completed_call_syntax(line_tokens, 1) => {
CompletionContext::DecisionOperator {
allow_not_equal: false,
}
}
Some(_)
if line_tokens
.iter()
.any(|token| matches!(token.kind, TokenKind::Equal)) =>
{
CompletionContext::Expression
}
Some(_)
if line_tokens
.iter()
.any(|token| matches!(token.kind, TokenKind::LeftParen)) =>
{
CompletionContext::Expression
}
_ => CompletionContext::None,
},
_ => CompletionContext::None,
}
}
fn case_completion_context(
line_tokens: &[Token],
tokens: &[Token],
declaration_start: usize,
line_start: usize,
) -> CompletionContext {
let Some(first) = line_tokens.first() else {
let expectation_seen = tokens.iter().any(|token| {
token.span.start >= declaration_start
&& token.span.start < line_start
&& matches!(token.kind, TokenKind::Keyword(Keyword::Expect))
});
return CompletionContext::CaseClause {
allow_let: !expectation_seen,
};
};
match first.kind {
TokenKind::Keyword(Keyword::Let)
if line_tokens
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::Equal)) =>
{
CompletionContext::CaseEntity
}
TokenKind::Keyword(Keyword::Expect) if line_tokens.len() == 1 => {
CompletionContext::CaseExpect
}
TokenKind::Keyword(Keyword::Expect) if completed_call_syntax(line_tokens, 1) => {
CompletionContext::DecisionOperator {
allow_not_equal: true,
}
}
TokenKind::Keyword(Keyword::Expect)
if line_tokens
.iter()
.any(|token| matches!(token.kind, TokenKind::Equal | TokenKind::NotEqual)) =>
{
CompletionContext::Expression
}
TokenKind::Keyword(Keyword::Expect)
if line_tokens
.iter()
.any(|token| matches!(token.kind, TokenKind::LeftParen)) =>
{
CompletionContext::Expression
}
_ => CompletionContext::None,
}
}
fn invariant_completion_context(
line_tokens: &[Token],
tokens: &[Token],
declaration_start: usize,
cursor: usize,
) -> CompletionContext {
let declaration_tokens: Vec<_> = tokens
.iter()
.filter(|token| {
token.span.start >= declaration_start
&& token.span.start < cursor
&& !matches!(token.kind, TokenKind::Newline | TokenKind::Eof)
})
.collect();
let has_for = declaration_tokens
.iter()
.any(|token| matches!(token.kind, TokenKind::Keyword(Keyword::For)));
let has_assertion = declaration_tokens.iter().any(|token| {
matches!(
token.kind,
TokenKind::Keyword(
Keyword::If
| Keyword::Exactly
| Keyword::Zero
| Keyword::Many
| Keyword::Cardinality
)
)
});
let has_if = declaration_tokens
.iter()
.any(|token| matches!(token.kind, TokenKind::Keyword(Keyword::If)));
let has_then = declaration_tokens
.iter()
.any(|token| matches!(token.kind, TokenKind::Keyword(Keyword::Then)));
let Some(first) = line_tokens.first() else {
return if !has_for {
CompletionContext::InvariantQuantifier
} else if !has_assertion {
CompletionContext::InvariantAssertion
} else if has_if && !has_then {
CompletionContext::InvariantThen
} else {
CompletionContext::None
};
};
if let Some(cardinality_length) = cardinality_prefix_length(line_tokens) {
if line_tokens.len() == cardinality_length {
return CompletionContext::InvariantDecision;
}
if line_tokens[cardinality_length..]
.iter()
.any(|token| matches!(token.kind, TokenKind::LeftParen))
{
return CompletionContext::Expression;
}
}
match first.kind {
TokenKind::Keyword(Keyword::For) => {
if line_tokens.len() == 1 {
CompletionContext::InvariantQuantifierKind
} else if line_tokens
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::Colon))
{
CompletionContext::ParameterType { scalar: false }
} else {
CompletionContext::None
}
}
TokenKind::Keyword(Keyword::If) => CompletionContext::Expression,
TokenKind::Keyword(Keyword::Then) if line_tokens.len() == 1 => {
CompletionContext::InvariantDecision
}
TokenKind::Keyword(Keyword::Then) if completed_call_syntax(line_tokens, 1) => {
CompletionContext::DecisionOperator {
allow_not_equal: true,
}
}
TokenKind::Keyword(Keyword::Then)
if line_tokens
.iter()
.any(|token| matches!(token.kind, TokenKind::Equal | TokenKind::NotEqual)) =>
{
CompletionContext::Expression
}
TokenKind::Keyword(Keyword::Then)
if line_tokens
.iter()
.any(|token| matches!(token.kind, TokenKind::LeftParen)) =>
{
CompletionContext::Expression
}
TokenKind::Keyword(Keyword::Exactly) if line_tokens.len() == 1 => {
CompletionContext::CardinalityOne
}
TokenKind::Keyword(Keyword::Zero) if line_tokens.len() == 1 => {
CompletionContext::CardinalityOrOne
}
TokenKind::Keyword(Keyword::Zero)
if matches!(
line_tokens.get(1).map(|token| &token.kind),
Some(TokenKind::Keyword(Keyword::Or))
) && line_tokens.len() == 2 =>
{
CompletionContext::CardinalityOne
}
TokenKind::Keyword(Keyword::Cardinality) if line_tokens.len() == 1 => {
CompletionContext::DecisionCardinality
}
_ => CompletionContext::None,
}
}
fn completed_call_syntax(tokens: &[Token], name_index: usize) -> bool {
let Some(name) = tokens.get(name_index) else {
return false;
};
if identifier(name).is_none()
|| !tokens
.get(name_index + 1)
.is_some_and(|token| matches!(token.kind, TokenKind::LeftParen))
|| !tokens
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::RightParen))
{
return false;
}
let mut depth = 0isize;
for token in &tokens[name_index + 1..] {
match token.kind {
TokenKind::LeftParen => depth += 1,
TokenKind::RightParen => depth -= 1,
_ => {}
}
if depth < 0 {
return false;
}
}
depth == 0
}
fn cardinality_prefix_length(tokens: &[Token]) -> Option<usize> {
if matches!(
tokens.get(..1),
Some([Token {
kind: TokenKind::Keyword(Keyword::Many),
..
}])
) {
Some(1)
} else if matches!(
tokens.get(..2),
Some([
Token {
kind: TokenKind::Keyword(Keyword::Exactly),
..
},
Token {
kind: TokenKind::Keyword(Keyword::One),
..
}
])
) {
Some(2)
} else if matches!(
tokens.get(..3),
Some([
Token {
kind: TokenKind::Keyword(Keyword::Zero),
..
},
Token {
kind: TokenKind::Keyword(Keyword::Or),
..
},
Token {
kind: TokenKind::Keyword(Keyword::One),
..
}
])
) {
Some(3)
} else {
None
}
}
fn candidates_for_context(
context: CompletionContext,
catalog: &CompletionCatalog,
candidate_context: &CompletionCandidateContext<'_>,
) -> Vec<Candidate> {
let CompletionCandidateContext {
current,
declaration,
text,
cursor,
line_tokens,
all_tokens,
} = *candidate_context;
match context {
CompletionContext::Module => vec![keyword_candidate("module", "start a Tess module")],
CompletionContext::TopLevel { allow_import } => [
allow_import.then(|| keyword_candidate("import", "import another Tess file")),
Some(keyword_candidate("enum", "declare a finite set of values")),
Some(keyword_candidate(
"entity",
"declare structured input facts",
)),
Some(keyword_candidate("derive", "declare a computed value")),
Some(keyword_candidate("decision", "declare a decision question")),
Some(keyword_candidate("source", "declare a policy source")),
Some(keyword_candidate("rule", "declare a policy rule")),
Some(keyword_candidate("case", "declare a concrete example")),
Some(keyword_candidate(
"invariant",
"declare a quantified property",
)),
]
.into_iter()
.flatten()
.collect(),
CompletionContext::ParameterType { scalar } => {
let mut candidates = Vec::new();
if scalar {
candidates.extend(scalar_type_candidates(catalog));
}
candidates.extend(entity_candidates(catalog));
candidates
}
CompletionContext::ReturnType { allow_entity } => {
let mut candidates = scalar_type_candidates(catalog);
if allow_entity {
candidates.extend(entity_candidates(catalog));
}
candidates
}
CompletionContext::EntityFieldType => scalar_type_candidates(catalog),
CompletionContext::EntityFieldModifier {
allow_optional,
allow_range,
allow_domain,
} => [
allow_optional.then(|| {
Candidate::new(
"?",
CompletionItemKind::OPERATOR,
"canonical optional field marker",
)
}),
allow_range.then(|| keyword_candidate("range", "numeric field constraint")),
allow_domain.then(|| keyword_candidate("domain", "finite exact field domain")),
]
.into_iter()
.flatten()
.collect(),
CompletionContext::DecisionCardinality => vec![
keyword_candidate("exactly one", "exactly one result (default)"),
keyword_candidate("zero or one", "an optional result"),
keyword_candidate("many", "multiple results"),
],
CompletionContext::SourceMetadata => {
let used = declaration.map_or_else(BTreeSet::new, |declaration| {
lexical_used_source_metadata(text, declaration, cursor)
});
[
("title", "required source title"),
("section", "source section"),
("version", "source version"),
("uri", "source URI"),
]
.into_iter()
.filter(|(name, _)| !used.contains(*name))
.map(|(name, detail)| Candidate::new(name, CompletionItemKind::FIELD, detail))
.collect()
}
CompletionContext::RuleClause {
allow_when,
allow_then,
allow_source,
} => [
allow_when.then(|| keyword_candidate("when", "rule condition")),
allow_then.then(|| keyword_candidate("then", "rule effect")),
allow_source.then(|| keyword_candidate("source", "rule source reference")),
]
.into_iter()
.flatten()
.collect(),
CompletionContext::RuleThen => {
let mut candidates = vec![keyword_candidate("override", "override another rule")];
candidates.extend(scope_compatible_decisions(
catalog,
declaration,
text,
cursor,
));
candidates
}
CompletionContext::RuleOverride => {
override_candidates(catalog, current, declaration, text, cursor)
}
CompletionContext::RuleSource => {
let used = declaration.map_or_else(BTreeSet::new, |declaration| {
lexical_used_rule_sources(text, declaration, cursor)
});
catalog
.sources
.values()
.filter(|source| !used.contains(&source.name))
.map(|source| {
Candidate::new(&source.name, CompletionItemKind::REFERENCE, &source.detail)
})
.collect()
}
CompletionContext::DecisionOperator { allow_not_equal } => [
Some(Candidate::new(
"=",
CompletionItemKind::OPERATOR,
"set or compare the decision result",
)),
allow_not_equal.then(|| {
Candidate::new(
"!=",
CompletionItemKind::OPERATOR,
"compare with a different decision result",
)
}),
]
.into_iter()
.flatten()
.collect(),
CompletionContext::CaseClause { allow_let } => [
allow_let.then(|| keyword_candidate("let", "bind an entity input")),
Some(keyword_candidate("expect", "assert a decision result")),
]
.into_iter()
.flatten()
.collect(),
CompletionContext::CaseEntity => entity_candidates(catalog),
CompletionContext::CaseField {
entity,
used_fields,
} => catalog
.entities
.get(&entity)
.map_or_else(Vec::new, |entity| {
entity
.fields
.iter()
.filter(|field| !field.optional && !used_fields.contains(&field.name))
.chain(
entity
.fields
.iter()
.filter(|field| field.optional && !used_fields.contains(&field.name)),
)
.map(|field| {
Candidate::new(&field.name, CompletionItemKind::FIELD, &field.detail)
})
.collect()
}),
CompletionContext::CaseFieldValue { entity, field } => catalog
.entities
.get(&entity)
.and_then(|entity| {
entity
.fields
.iter()
.find(|candidate| candidate.name == field)
})
.map_or_else(Vec::new, |field| {
if field.domain_values.is_empty() {
typed_value_candidates(catalog, &field.ty, field.optional)
} else {
let mut values = field
.domain_values
.iter()
.map(|value| {
Candidate::new(
value,
CompletionItemKind::VALUE,
"declared field domain",
)
})
.collect::<Vec<_>>();
if field.optional {
values.push(keyword_candidate("unknown", "unknown optional value"));
}
values
}
}),
CompletionContext::CaseExpect | CompletionContext::InvariantDecision => {
scope_compatible_decisions(catalog, declaration, text, cursor)
}
CompletionContext::InvariantQuantifier => {
vec![
keyword_candidate("for all", "require every input to satisfy the invariant"),
keyword_candidate(
"for some",
"require at least one input to satisfy the invariant",
),
]
}
CompletionContext::InvariantQuantifierKind => vec![
keyword_candidate("all", "require every input to satisfy the invariant"),
keyword_candidate(
"some",
"require at least one input to satisfy the invariant",
),
],
CompletionContext::InvariantAssertion => vec![
keyword_candidate("exactly one", "require exactly one decision result"),
keyword_candidate("zero or one", "allow at most one decision result"),
keyword_candidate("many", "allow multiple decision results"),
keyword_candidate("if", "start an implication condition"),
],
CompletionContext::CardinalityOne => {
vec![keyword_candidate("one", "complete the cardinality")]
}
CompletionContext::CardinalityOrOne => {
vec![keyword_candidate("or one", "complete the cardinality")]
}
CompletionContext::InvariantThen => {
vec![keyword_candidate("then", "invariant consequence")]
}
CompletionContext::Expression => {
expression_candidates(catalog, declaration, text, cursor, line_tokens, all_tokens)
}
CompletionContext::None => Vec::new(),
}
}
fn keyword_candidate(label: &str, detail: &str) -> Candidate {
Candidate::new(label, CompletionItemKind::KEYWORD, detail)
}
fn scope_compatible_decisions(
catalog: &CompletionCatalog,
declaration: Option<&LexicalDeclaration>,
text: &str,
cursor: usize,
) -> Vec<Candidate> {
let Some(declaration) = declaration else {
return Vec::new();
};
let available_types = lexical_scope(text, declaration, cursor)
.into_iter()
.map(|value| value.ty)
.collect::<BTreeSet<_>>();
catalog
.decisions
.values()
.filter(|decision| {
decision
.parameter_types
.iter()
.all(|required| available_types.contains(required))
})
.map(|decision| {
Candidate::new(
&decision.name,
CompletionItemKind::FUNCTION,
&decision.detail,
)
})
.collect()
}
fn entity_candidates(catalog: &CompletionCatalog) -> Vec<Candidate> {
catalog
.entities
.iter()
.map(|(name, entity)| Candidate::new(name, CompletionItemKind::CLASS, &entity.detail))
.collect()
}
fn scalar_type_candidates(catalog: &CompletionCatalog) -> Vec<Candidate> {
let mut candidates = ["Bool", "Int", "Decimal", "String", "Date", "Duration"]
.into_iter()
.map(|name| {
Candidate::new(
name,
CompletionItemKind::TYPE_PARAMETER,
"built-in scalar type",
)
})
.collect::<Vec<_>>();
candidates.extend(
catalog
.enums
.values()
.map(|value| Candidate::new(&value.name, CompletionItemKind::ENUM, &value.detail)),
);
candidates
}
fn expression_candidates(
catalog: &CompletionCatalog,
declaration: Option<&LexicalDeclaration>,
text: &str,
cursor: usize,
line_tokens: &[Token],
all_tokens: &[Token],
) -> Vec<Candidate> {
if let Some(candidates) = decision_rhs_candidates(catalog, line_tokens) {
return candidates;
}
let declaration_tokens = declaration.map_or(all_tokens, |declaration| {
let start = all_tokens.partition_point(|token| token.span.start < declaration.start);
&all_tokens[start..]
});
if let Some(expected_type) = callable_argument_type(catalog, declaration_tokens) {
if !expression_expects_operand(line_tokens) {
return Vec::new();
}
return declaration.map_or_else(Vec::new, |declaration| {
lexical_scope(text, declaration, cursor)
.into_iter()
.filter(|value| expected_type.accepts(&value.ty))
.map(|value| {
Candidate::new(&value.name, CompletionItemKind::VARIABLE, &value.detail)
})
.collect()
});
}
if let Some((expected_type, allow_unknown)) =
expected_expression_type(catalog, declaration, text, cursor, line_tokens)
{
let typed = typed_value_candidates(catalog, &expected_type, allow_unknown);
if !typed.is_empty() {
return typed;
}
}
if completed_decision_call(catalog, line_tokens) {
return Vec::new();
}
if !expression_expects_operand(line_tokens) {
if has_top_level_comparison(line_tokens) {
return logical_operator_candidates();
}
if let Some((ty, _)) = operand_type(catalog, declaration, text, cursor, line_tokens) {
return typed_operator_candidates(catalog, &ty);
}
return logical_operator_candidates();
}
let mut candidates = vec![
keyword_candidate("not", "logical negation"),
keyword_candidate("true", "boolean literal"),
keyword_candidate("false", "boolean literal"),
keyword_candidate("unknown", "unknown optional value"),
];
if let Some(declaration) = declaration {
candidates.extend(
lexical_scope(text, declaration, cursor)
.into_iter()
.map(|value| {
Candidate::new(&value.name, CompletionItemKind::VARIABLE, &value.detail)
}),
);
}
candidates.extend(
catalog.derives.values().map(|derive| {
Candidate::new(&derive.name, CompletionItemKind::FUNCTION, &derive.detail)
}),
);
candidates.extend([
Candidate::new("date", CompletionItemKind::FUNCTION, "builtin · date(text)"),
Candidate::new(
"days",
CompletionItemKind::FUNCTION,
"builtin · days(count)",
),
Candidate::new(
"hours",
CompletionItemKind::FUNCTION,
"builtin · hours(count)",
),
Candidate::new(
"minutes",
CompletionItemKind::FUNCTION,
"builtin · minutes(count)",
),
Candidate::new("abs", CompletionItemKind::FUNCTION, "builtin · abs(value)"),
Candidate::new(
"max",
CompletionItemKind::FUNCTION,
"builtin · max(left, right)",
),
Candidate::new(
"min",
CompletionItemKind::FUNCTION,
"builtin · min(left, right)",
),
]);
candidates
}
fn typed_value_candidates(
catalog: &CompletionCatalog,
expected: &TypeRef,
allow_unknown: bool,
) -> Vec<Candidate> {
let mut candidates = match expected {
TypeRef::Bool => vec![
keyword_candidate("true", "boolean literal"),
keyword_candidate("false", "boolean literal"),
],
TypeRef::Named(enum_name) => catalog
.variants
.values()
.filter(|variant| variant.enum_name == *enum_name)
.map(|variant| {
Candidate::new(
&variant.name,
CompletionItemKind::ENUM_MEMBER,
&variant.detail,
)
})
.collect(),
TypeRef::Date => vec![Candidate::new(
"date",
CompletionItemKind::FUNCTION,
"builtin · date(text)",
)],
TypeRef::Duration => ["days", "hours", "minutes"]
.into_iter()
.map(|name| {
Candidate::new(
name,
CompletionItemKind::FUNCTION,
&format!("builtin · {name}(count)"),
)
})
.collect(),
TypeRef::Int | TypeRef::Decimal | TypeRef::String | TypeRef::Unknown => Vec::new(),
};
if allow_unknown {
candidates.push(keyword_candidate("unknown", "unknown optional value"));
}
candidates
}
fn logical_operator_candidates() -> Vec<Candidate> {
vec![
keyword_candidate("and", "logical conjunction"),
keyword_candidate("or", "logical disjunction"),
]
}
fn typed_operator_candidates(catalog: &CompletionCatalog, ty: &TypeRef) -> Vec<Candidate> {
let operator = |label, detail| Candidate::new(label, CompletionItemKind::OPERATOR, detail);
match ty {
TypeRef::Bool => {
let mut candidates = logical_operator_candidates();
candidates.extend([
operator("=", "compare equal boolean values"),
operator("!=", "compare different boolean values"),
]);
candidates
}
TypeRef::Int | TypeRef::Decimal => vec![
operator("=", "compare equal numeric values"),
operator("!=", "compare different numeric values"),
operator(">", "compare greater numeric value"),
operator(">=", "compare greater-or-equal numeric value"),
operator("<", "compare lesser numeric value"),
operator("<=", "compare lesser-or-equal numeric value"),
operator("+", "add numeric values"),
operator("-", "subtract numeric values"),
operator("*", "multiply numeric values"),
operator("/", "divide numeric values"),
],
TypeRef::String | TypeRef::Date | TypeRef::Duration => vec![
operator("=", "compare equal values"),
operator("!=", "compare different values"),
operator(">", "compare greater value"),
operator(">=", "compare greater-or-equal value"),
operator("<", "compare lesser value"),
operator("<=", "compare lesser-or-equal value"),
],
TypeRef::Named(name) if catalog.enums.contains_key(name) => vec![
operator("=", "compare equal enum values"),
operator("!=", "compare different enum values"),
],
TypeRef::Named(_) | TypeRef::Unknown => Vec::new(),
}
}
fn has_top_level_comparison(tokens: &[Token]) -> bool {
let mut depth = 0usize;
tokens.iter().any(|token| {
match token.kind {
TokenKind::LeftParen => depth += 1,
TokenKind::RightParen => depth = depth.saturating_sub(1),
_ => {}
}
depth == 0
&& matches!(
token.kind,
TokenKind::Equal
| TokenKind::NotEqual
| TokenKind::Greater
| TokenKind::GreaterEqual
| TokenKind::Less
| TokenKind::LessEqual
)
})
}
fn decision_rhs_candidates(
catalog: &CompletionCatalog,
tokens: &[Token],
) -> Option<Vec<Candidate>> {
if !matches!(
tokens.first().map(|token| &token.kind),
Some(TokenKind::Keyword(Keyword::Then | Keyword::Expect))
) {
return None;
}
let equal = tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::Equal | TokenKind::NotEqual))?;
let name = tokens.get(1).and_then(identifier)?;
if !tokens[2..equal]
.iter()
.any(|token| matches!(token.kind, TokenKind::RightParen))
{
return None;
}
let decision = catalog.decisions.get(name)?;
Some(typed_value_candidates(
catalog,
&decision.return_type,
false,
))
}
#[derive(Clone, Copy)]
enum ArgumentExpectation<'a> {
Exact(&'a str),
Numeric,
}
impl ArgumentExpectation<'_> {
fn accepts(self, actual: &str) -> bool {
match self {
Self::Exact(expected) => actual == expected,
Self::Numeric => matches!(actual, "Int" | "Decimal"),
}
}
}
fn callable_argument_type<'a>(
catalog: &'a CompletionCatalog,
tokens: &[Token],
) -> Option<ArgumentExpectation<'a>> {
let (name, argument_index) = open_call_argument(tokens)?;
if let Some(expected) = catalog
.decisions
.get(name)
.map(|decision| &decision.parameter_types)
.or_else(|| {
catalog
.derives
.get(name)
.map(|derive| &derive.parameter_types)
})
.and_then(|parameters| parameters.get(argument_index))
{
return Some(ArgumentExpectation::Exact(expected));
}
match (name, argument_index) {
("date", 0) => Some(ArgumentExpectation::Exact("String")),
("days" | "hours" | "minutes", 0) => Some(ArgumentExpectation::Exact("Int")),
("abs", 0) | ("max" | "min", 0 | 1) => Some(ArgumentExpectation::Numeric),
_ => None,
}
}
fn open_call_argument(tokens: &[Token]) -> Option<(&str, usize)> {
let mut depth = 0usize;
let mut open = None;
for (index, token) in tokens.iter().enumerate().rev() {
match token.kind {
TokenKind::RightParen => depth += 1,
TokenKind::LeftParen if depth == 0 => {
open = Some(index);
break;
}
TokenKind::LeftParen => depth -= 1,
_ => {}
}
}
let open = open?;
let name = open
.checked_sub(1)
.and_then(|index| identifier(&tokens[index]))?;
let mut nested = 0usize;
let mut argument_index = 0usize;
for token in &tokens[open + 1..] {
match token.kind {
TokenKind::LeftParen => nested += 1,
TokenKind::RightParen => nested = nested.saturating_sub(1),
TokenKind::Comma if nested == 0 => argument_index += 1,
_ => {}
}
}
Some((name, argument_index))
}
fn expected_expression_type(
catalog: &CompletionCatalog,
declaration: Option<&LexicalDeclaration>,
text: &str,
cursor: usize,
line_tokens: &[Token],
) -> Option<(TypeRef, bool)> {
if let Some(expected) = comparison_rhs_type(catalog, declaration, text, cursor, line_tokens) {
return Some(expected);
}
let declaration = declaration?;
if declaration.kind == DeclarationKind::Derive
&& derive_expression_line_active(text, declaration, cursor)
&& (line_tokens.is_empty()
|| line_tokens
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::Colon)))
{
return lexical_derive_return_type(text, declaration, cursor).map(|ty| (ty, false));
}
None
}
fn comparison_rhs_type(
catalog: &CompletionCatalog,
declaration: Option<&LexicalDeclaration>,
text: &str,
cursor: usize,
tokens: &[Token],
) -> Option<(TypeRef, bool)> {
let operator = tokens
.iter()
.rposition(|token| matches!(token.kind, TokenKind::Equal | TokenKind::NotEqual))?;
if operator + 1 != tokens.len() {
return None;
}
operand_type(catalog, declaration, text, cursor, &tokens[..operator])
}
fn operand_type(
catalog: &CompletionCatalog,
declaration: Option<&LexicalDeclaration>,
text: &str,
cursor: usize,
tokens: &[Token],
) -> Option<(TypeRef, bool)> {
let field_access = match tokens {
[.., receiver, dot, field] if matches!(dot.kind, TokenKind::Dot) => Some((receiver, field)),
_ => None,
};
if let Some((receiver, field)) = field_access {
let field = identifier(field)?;
let entity_name = if let Some(receiver) = identifier(receiver) {
declaration.and_then(|declaration| {
lexical_receiver_entity(text, declaration, cursor, receiver)
})
} else if matches!(receiver.kind, TokenKind::RightParen) {
let receiver_index = tokens.len().checked_sub(3)?;
completed_call_name(tokens, receiver_index)
.and_then(|name| catalog.derives.get(name))
.and_then(|derive| match &derive.return_type {
TypeRef::Named(name) if catalog.entities.contains_key(name) => {
Some(name.clone())
}
_ => None,
})
} else {
None
}?;
let field = catalog
.entities
.get(&entity_name)?
.fields
.iter()
.find(|candidate| candidate.name == field)?;
return Some((field.ty.clone(), field.optional));
}
let right = tokens.len().checked_sub(1)?;
let completed_derive = match tokens[right].kind {
TokenKind::RightParen => {
completed_call_name(tokens, right).and_then(|name| catalog.derives.get(name))
}
_ => None,
};
if let Some(derive) = completed_derive {
return Some((derive.return_type.clone(), false));
}
match &tokens[right].kind {
TokenKind::Number(value) => Some((
if value.contains('.') {
TypeRef::Decimal
} else {
TypeRef::Int
},
false,
)),
TokenKind::String(_) => Some((TypeRef::String, false)),
TokenKind::Keyword(Keyword::True | Keyword::False) => Some((TypeRef::Bool, false)),
TokenKind::Keyword(Keyword::Unknown) => Some((TypeRef::Unknown, true)),
TokenKind::Identifier(name) => {
if let Some(value) = declaration.and_then(|declaration| {
lexical_scope(text, declaration, cursor)
.into_iter()
.find(|value| value.name == *name)
}) {
return Some((type_ref_from_name(&value.ty), false));
}
let mut owners = catalog
.variants
.values()
.filter(|variant| variant.name == *name)
.map(|variant| variant.enum_name.as_str());
let owner = owners.next()?;
owners
.next()
.is_none()
.then(|| (TypeRef::Named(owner.to_owned()), false))
}
_ => None,
}
}
fn completed_call_name(tokens: &[Token], right: usize) -> Option<&str> {
if !tokens
.get(right)
.is_some_and(|token| matches!(token.kind, TokenKind::RightParen))
{
return None;
}
let mut depth = 0usize;
for index in (0..=right).rev() {
match tokens[index].kind {
TokenKind::RightParen => depth += 1,
TokenKind::LeftParen => {
depth = depth.saturating_sub(1);
if depth == 0 {
return index
.checked_sub(1)
.and_then(|callee| identifier(&tokens[callee]));
}
}
_ => {}
}
}
None
}
fn lexical_derive_return_type(
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) -> Option<TypeRef> {
let tokens = lexical_declaration_tokens(text, declaration, cursor);
let arrow = tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::Arrow))?;
let name = tokens.get(arrow + 1).and_then(identifier)?;
Some(type_ref_from_name(name))
}
fn type_ref_from_name(name: &str) -> TypeRef {
match name {
"Bool" => TypeRef::Bool,
"Int" => TypeRef::Int,
"Decimal" => TypeRef::Decimal,
"String" => TypeRef::String,
"Date" => TypeRef::Date,
"Duration" => TypeRef::Duration,
_ => TypeRef::Named(name.to_owned()),
}
}
fn completed_decision_call(catalog: &CompletionCatalog, tokens: &[Token]) -> bool {
let Some(right_paren) = tokens
.iter()
.rposition(|token| matches!(token.kind, TokenKind::RightParen))
else {
return false;
};
if right_paren + 1 != tokens.len() {
return false;
}
tokens[..right_paren].windows(2).any(|window| {
identifier(&window[0]).is_some_and(|name| catalog.decisions.contains_key(name))
&& matches!(window[1].kind, TokenKind::LeftParen)
})
}
fn expression_expects_operand(tokens: &[Token]) -> bool {
let Some(last) = tokens.last() else {
return true;
};
matches!(
last.kind,
TokenKind::Keyword(
Keyword::When
| Keyword::If
| Keyword::Then
| Keyword::Expect
| Keyword::And
| Keyword::Or
| Keyword::Not
) | TokenKind::LeftParen
| TokenKind::Comma
| TokenKind::Equal
| TokenKind::NotEqual
| TokenKind::Greater
| TokenKind::GreaterEqual
| TokenKind::Less
| TokenKind::LessEqual
| TokenKind::Plus
| TokenKind::Minus
| TokenKind::Star
| TokenKind::Slash
| TokenKind::Colon
)
}
fn override_candidates(
catalog: &CompletionCatalog,
current: Option<&Program>,
declaration: Option<&LexicalDeclaration>,
text: &str,
cursor: usize,
) -> Vec<Candidate> {
let current_name = declaration.and_then(|declaration| declaration.name.as_deref());
let current_types = declaration
.and_then(|declaration| lexical_parameter_types(text, declaration, cursor))
.or_else(|| {
current.and_then(|program| {
let Declaration::Rule(rule) = active_declaration(program, cursor)? else {
return None;
};
Some(
rule.parameters
.iter()
.map(|parameter| parameter.ty.value.clone())
.collect::<Vec<_>>(),
)
})
});
let used = declaration.map_or_else(BTreeSet::new, |declaration| {
lexical_used_rule_overrides(text, declaration, cursor)
});
catalog
.rules
.values()
.filter(|rule| Some(rule.name.as_str()) != current_name)
.filter(|rule| !used.contains(&rule.name))
.filter(|rule| {
current_types
.as_ref()
.is_none_or(|types| rule.parameter_types == *types)
})
.filter(|rule| {
current_name.is_none_or(|current_name| {
!rule_reaches(catalog, &rule.name, current_name, &mut BTreeSet::new())
})
})
.map(|rule| Candidate::new(&rule.name, CompletionItemKind::REFERENCE, &rule.detail))
.collect()
}
fn rule_reaches(
catalog: &CompletionCatalog,
rule_name: &str,
target: &str,
visited: &mut BTreeSet<String>,
) -> bool {
if rule_name == target {
return true;
}
if !visited.insert(rule_name.to_owned()) {
return false;
}
catalog.rules.get(rule_name).is_some_and(|rule| {
rule.overrides
.iter()
.any(|overridden| rule_reaches(catalog, overridden, target, visited))
})
}
fn lexical_parameter_types(
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) -> Option<Vec<String>> {
if !matches!(
declaration.kind,
DeclarationKind::Rule | DeclarationKind::Decision | DeclarationKind::Derive
) {
return None;
}
let fragment = text.get(declaration.start..cursor)?;
let lexed = lex(&SourceFile::new(
"<completion-parameters>",
fragment.to_owned(),
));
let tokens: Vec<_> = lexed
.tokens
.iter()
.filter(|token| !matches!(token.kind, TokenKind::Newline | TokenKind::Eof))
.collect();
let open = tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::LeftParen))?;
let end = tokens[open + 1..]
.iter()
.position(|token| matches!(token.kind, TokenKind::RightParen))
.map(|relative| open + 1 + relative)?;
let mut parameter_types = Vec::new();
for parameter in tokens[open + 1..end].split(|token| matches!(token.kind, TokenKind::Comma)) {
if parameter.is_empty() {
continue;
}
let [ty, name] = parameter else {
return None;
};
let ty = identifier(ty)?;
identifier(name)?;
parameter_types.push(ty.to_owned());
}
Some(parameter_types)
}
fn lexical_declaration_tokens(
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) -> Vec<Token> {
let Some(fragment) = text.get(declaration.start..cursor) else {
return Vec::new();
};
lex(&SourceFile::new(
"<completion-declaration-fragment>",
fragment.to_owned(),
))
.tokens
.into_iter()
.filter(|token| !matches!(token.kind, TokenKind::Newline | TokenKind::Eof))
.collect()
}
fn lexical_used_source_metadata(
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) -> BTreeSet<String> {
if declaration.kind != DeclarationKind::Source {
return BTreeSet::new();
}
lexical_declaration_tokens(text, declaration, cursor)
.windows(2)
.filter_map(|window| {
let [field, colon] = window else {
return None;
};
matches!(colon.kind, TokenKind::Colon)
.then(|| identifier(field).map(str::to_owned))
.flatten()
})
.collect()
}
fn lexical_used_rule_sources(
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) -> BTreeSet<String> {
if declaration.kind != DeclarationKind::Rule {
return BTreeSet::new();
}
lexical_declaration_tokens(text, declaration, cursor)
.windows(2)
.filter_map(|window| {
let [source, name] = window else {
return None;
};
matches!(source.kind, TokenKind::Keyword(Keyword::Source))
.then(|| identifier(name).map(str::to_owned))
.flatten()
})
.collect()
}
fn lexical_used_rule_overrides(
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) -> BTreeSet<String> {
if declaration.kind != DeclarationKind::Rule {
return BTreeSet::new();
}
lexical_declaration_tokens(text, declaration, cursor)
.windows(2)
.filter_map(|window| {
let [r#override, name] = window else {
return None;
};
matches!(r#override.kind, TokenKind::Keyword(Keyword::Override))
.then(|| identifier(name).map(str::to_owned))
.flatten()
})
.collect()
}
fn active_lexical_declaration(text: &str, cursor: usize) -> Option<LexicalDeclaration> {
let before = text.get(..cursor)?;
let lexed = lex(&SourceFile::new(
"<completion-declaration>",
before.to_owned(),
));
let tokens = &lexed.tokens;
let mut active = None;
for (index, token) in tokens.iter().enumerate() {
let Some(kind) = declaration_keyword(token) else {
continue;
};
let line_start = before[..token.span.start]
.rfind(['\n', '\r'])
.map_or(0, |newline| newline + 1);
if line_start != token.span.start {
continue;
}
let name = tokens[index + 1..]
.iter()
.take_while(|candidate| !matches!(candidate.kind, TokenKind::Newline | TokenKind::Eof))
.find_map(|candidate| identifier(candidate).map(str::to_owned));
active = Some(LexicalDeclaration {
kind,
start: token.span.start,
name,
});
}
active
}
fn declaration_keyword(token: &Token) -> Option<DeclarationKind> {
let TokenKind::Keyword(keyword) = token.kind else {
return None;
};
Some(match keyword {
Keyword::Enum => DeclarationKind::Enum,
Keyword::Entity => DeclarationKind::Entity,
Keyword::Derive => DeclarationKind::Derive,
Keyword::Decision => DeclarationKind::Decision,
Keyword::Source => DeclarationKind::Source,
Keyword::Rule => DeclarationKind::Rule,
Keyword::Case => DeclarationKind::Case,
Keyword::Invariant => DeclarationKind::Invariant,
_ => return None,
})
}
fn has_module_header(text: &str, before: usize) -> bool {
let Some(prefix) = text.get(..before) else {
return false;
};
let lexed = lex(&SourceFile::new("<completion-module>", prefix.to_owned()));
lexed.tokens.iter().any(|token| {
matches!(token.kind, TokenKind::Keyword(Keyword::Module))
&& prefix[..token.span.start]
.rfind(['\n', '\r'])
.map_or(0, |newline| newline + 1)
== token.span.start
})
}
struct OpenCaseBinding {
entity: String,
used_fields: BTreeSet<String>,
}
fn open_case_binding(tokens: &[Token], start: usize, cursor: usize) -> Option<OpenCaseBinding> {
let significant: Vec<_> = tokens
.iter()
.filter(|token| {
token.span.start >= start
&& token.span.start < cursor
&& !matches!(token.kind, TokenKind::Newline | TokenKind::Eof)
})
.collect();
let mut braces = Vec::new();
for (index, token) in significant.iter().enumerate() {
match token.kind {
TokenKind::LeftBrace => braces.push(index),
TokenKind::RightBrace => {
braces.pop();
}
_ => {}
}
}
let open = braces.last().copied()?;
let entity = significant.get(open.checked_sub(1)?)?;
let entity = identifier(entity)?.to_owned();
let used_fields = significant[open + 1..]
.windows(2)
.filter_map(|window| {
let [field, colon] = window else {
return None;
};
matches!(colon.kind, TokenKind::Colon)
.then(|| identifier(field).map(str::to_owned))
.flatten()
})
.collect();
Some(OpenCaseBinding {
entity,
used_fields,
})
}
fn lexical_receiver_entity(
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
receiver: &str,
) -> Option<String> {
lexical_scope(text, declaration, cursor)
.into_iter()
.find(|value| value.name == receiver)
.map(|value| value.ty)
}
fn lexical_member_scope_active(
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) -> bool {
match declaration.kind {
DeclarationKind::Rule | DeclarationKind::Case | DeclarationKind::Invariant => true,
DeclarationKind::Derive => derive_expression_line_active(text, declaration, cursor),
DeclarationKind::Enum
| DeclarationKind::Entity
| DeclarationKind::Decision
| DeclarationKind::Source => false,
}
}
fn derive_expression_line_active(
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) -> bool {
let Some(fragment) = text.get(declaration.start..cursor) else {
return false;
};
let lexed = lex(&SourceFile::new(
"<completion-derive-expression>",
fragment.to_owned(),
));
let Some(colon) = lexed
.tokens
.iter()
.rposition(|token| matches!(token.kind, TokenKind::Colon))
else {
return false;
};
let first_expression = lexed.tokens[colon + 1..]
.iter()
.find(|token| !matches!(token.kind, TokenKind::Newline | TokenKind::Eof));
first_expression.is_none_or(|token| {
let expression_line = fragment[..token.span.start]
.rfind(['\n', '\r'])
.map_or(0, |newline| newline + 1);
let cursor_line = fragment
.rfind(['\n', '\r'])
.map_or(0, |newline| newline + 1);
expression_line == cursor_line
})
}
fn lexical_scope(
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) -> Vec<ScopedCompletion> {
let Some(fragment) = text.get(declaration.start..cursor) else {
return Vec::new();
};
let lexed = lex(&SourceFile::new("<completion-scope>", fragment.to_owned()));
let tokens: Vec<_> = lexed
.tokens
.iter()
.filter(|token| !matches!(token.kind, TokenKind::Newline | TokenKind::Eof))
.collect();
let mut values = BTreeMap::new();
match declaration.kind {
DeclarationKind::Rule | DeclarationKind::Decision | DeclarationKind::Derive => {
if let Some(open) = tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::LeftParen))
{
for pair in tokens[open + 1..]
.split(|token| matches!(token.kind, TokenKind::Comma | TokenKind::RightParen))
{
let [ty, name, ..] = pair else {
continue;
};
if let (Some(ty), Some(name)) = (identifier(ty), identifier(name)) {
values.insert(
name.to_owned(),
ScopedCompletion {
name: name.to_owned(),
ty: ty.to_owned(),
detail: format!("variable · {ty}"),
},
);
}
}
}
}
DeclarationKind::Invariant => {
for window in tokens.windows(3) {
let [name, colon, ty] = window else {
continue;
};
if !matches!(colon.kind, TokenKind::Colon) {
continue;
}
if let (Some(name), Some(ty)) = (identifier(name), identifier(ty)) {
values.insert(
name.to_owned(),
ScopedCompletion {
name: name.to_owned(),
ty: ty.to_owned(),
detail: format!("variable · {ty}"),
},
);
}
}
}
DeclarationKind::Case => {
for window in tokens.windows(4) {
let [let_token, name, equal, entity] = window else {
continue;
};
if !matches!(let_token.kind, TokenKind::Keyword(Keyword::Let))
|| !matches!(equal.kind, TokenKind::Equal)
{
continue;
}
if let (Some(name), Some(entity)) = (identifier(name), identifier(entity)) {
values.insert(
name.to_owned(),
ScopedCompletion {
name: name.to_owned(),
ty: entity.to_owned(),
detail: format!("variable · {entity}"),
},
);
}
}
}
DeclarationKind::Enum | DeclarationKind::Entity | DeclarationKind::Source => {}
}
values.into_values().collect()
}
fn callable_detail(kind: &str, parameters: &[Parameter], return_type: &str) -> String {
format!("{kind} · {} -> {return_type}", parameter_list(parameters))
}
fn parameter_list(parameters: &[Parameter]) -> String {
format!(
"({})",
parameters
.iter()
.map(|parameter| format!("{} {}", parameter.ty.value, parameter.name.value))
.collect::<Vec<_>>()
.join(", ")
)
}
fn receiver_entity(program: &Program, cursor: usize, receiver: &str) -> Option<String> {
let declaration = active_declaration(program, cursor)?;
match declaration {
Declaration::Rule(rule) => parameter_entity(&rule.parameters, receiver),
Declaration::Derive(derive) => parameter_entity(&derive.parameters, receiver),
Declaration::Decision(decision) => parameter_entity(&decision.parameters, receiver),
Declaration::Action(action) => parameter_entity(&action.parameters, receiver),
Declaration::Transition(transition) => parameter_entity(&transition.parameters, receiver),
Declaration::Invariant(invariant) => parameter_entity(&invariant.variables, receiver),
Declaration::Trace(trace) if trace.variable.name.value == receiver => {
Some(trace.variable.ty.value.clone())
}
Declaration::Case(case) => case
.bindings
.iter()
.find(|binding| binding.name.value == receiver)
.map(|binding| binding.entity.value.clone()),
_ => None,
}
}
fn active_declaration(program: &Program, cursor: usize) -> Option<&Declaration> {
program
.declarations
.iter()
.filter(|declaration| declaration.span().start <= cursor)
.max_by_key(|declaration| declaration.span().start)
}
fn parameter_entity(parameters: &[Parameter], receiver: &str) -> Option<String> {
parameters
.iter()
.find(|parameter| parameter.name.value == receiver)
.map(|parameter| parameter.ty.value.clone())
}
fn completion_programs(
uri: &Uri,
document: &OpenDocument,
documents: &BTreeMap<String, OpenDocument>,
) -> Vec<Program> {
let mut programs = Vec::new();
let Some(document_path) = file_uri_path(uri) else {
return programs;
};
let Ok(canonical_document) = document_path.canonicalize() else {
return programs;
};
let Ok(target) = crate::project::resolve_document_target(&canonical_document) else {
return programs;
};
let (Ok(entry), Ok(root)) = (target.entry.canonicalize(), target.root.canonicalize()) else {
return programs;
};
let mut overrides = BTreeMap::new();
overrides.insert(canonical_document, document.text.clone());
for (open_uri, open_document) in documents {
let Ok(open_uri) = open_uri.parse::<Uri>() else {
continue;
};
let Some(path) = file_uri_path(&open_uri) else {
continue;
};
let Ok(path) = path.canonicalize() else {
continue;
};
overrides.insert(path, open_document.text.clone());
}
let compiled = compile_project_with_dependency_graph_and_overrides(
&entry,
&root,
&target.dependencies,
&overrides,
)
.ok()
.and_then(|output| output.compile.program);
if let Some(program) = compiled {
return vec![program.ast().clone()];
}
let mut visited = BTreeSet::new();
collect_completion_programs(&entry, &root, &overrides, &mut visited, &mut programs);
programs
}
fn collect_completion_programs(
path: &Path,
root: &Path,
overrides: &BTreeMap<PathBuf, String>,
visited: &mut BTreeSet<PathBuf>,
programs: &mut Vec<Program>,
) {
if !path.starts_with(root) || !visited.insert(path.to_path_buf()) {
return;
}
let text = overrides
.get(path)
.cloned()
.or_else(|| fs::read_to_string(path).ok());
let Some(text) = text else {
return;
};
let parsed = parse(SourceFile::new(path.display().to_string(), text));
let Some(program) = parsed.program else {
return;
};
let imports = program.imports.clone();
programs.push(program);
for import in imports {
let requested = Path::new(&import.path.value);
if requested.is_absolute()
|| requested
.extension()
.and_then(|extension| extension.to_str())
!= Some("tes")
{
continue;
}
let Some(parent) = path.parent() else {
continue;
};
let Ok(imported) = parent.join(requested).canonicalize() else {
continue;
};
collect_completion_programs(&imported, root, overrides, visited, programs);
}
}
fn source_detail(source: &tess::ast::SourceDecl) -> String {
source.section.as_ref().map_or_else(
|| format!("source · {}", source.title.value),
|section| format!("source · {} · {}", source.title.value, section.value),
)
}
fn field_detail(entity: &str, field: &tess::ast::FieldDecl) -> String {
let mut detail = format!("{entity} · {}", type_name(&field.ty));
if let Some(range) = &field.range {
let _ = write!(
detail,
" range {}..{}",
numeric_literal(&range.start),
numeric_literal(&range.end)
);
}
if let Some(domain) = &field.domain {
let _ = write!(detail, " domain · {} values", domain.values.len());
}
if field.optional {
detail.push_str(" · optional");
}
detail
}
fn type_name(ty: &TypeRef) -> &str {
match ty {
TypeRef::Bool => "Bool",
TypeRef::Int => "Int",
TypeRef::Decimal => "Decimal",
TypeRef::String => "String",
TypeRef::Date => "Date",
TypeRef::Duration => "Duration",
TypeRef::Named(name) => name,
TypeRef::Unknown => "unknown",
}
}
fn numeric_literal(value: &NumericLiteral) -> String {
match value {
NumericLiteral::Int(value) => value.to_string(),
NumericLiteral::Decimal(value) => value.clone(),
}
}
fn handle_notification(
connection: &Connection,
notification: ServerNotification,
documents: &mut BTreeMap<String, OpenDocument>,
) -> LspResult<()> {
match notification.method.as_str() {
DidOpenTextDocument::METHOD => {
let parameters: DidOpenTextDocumentParams =
serde_json::from_value(notification.params)?;
let item = parameters.text_document;
let uri = item.uri;
documents.insert(
uri.as_str().to_owned(),
OpenDocument {
text: item.text,
version: item.version,
},
);
publish_open_documents(connection, documents)?;
}
DidChangeTextDocument::METHOD => {
let parameters: DidChangeTextDocumentParams =
serde_json::from_value(notification.params)?;
let uri = parameters.text_document.uri;
let version = parameters.text_document.version;
let mut changes = parameters.content_changes.into_iter();
let Some(change) = changes.next() else {
return Ok(());
};
if changes.next().is_some() || change.range.is_some() {
return Err(
"received incremental content after advertising one full-document change"
.into(),
);
}
let Some(document) = documents.get_mut(uri.as_str()) else {
return Err(
format!("received a change for unopened document `{}`", uri.as_str()).into(),
);
};
if version <= document.version {
return Ok(());
}
document.text = change.text;
document.version = version;
publish_open_documents(connection, documents)?;
}
DidSaveTextDocument::METHOD => {
let parameters: DidSaveTextDocumentParams =
serde_json::from_value(notification.params)?;
let uri = parameters.text_document.uri;
if let (Some(text), Some(document)) = (parameters.text, documents.get_mut(uri.as_str()))
{
document.text = text;
}
publish_open_documents(connection, documents)?;
}
DidCloseTextDocument::METHOD => {
let parameters: DidCloseTextDocumentParams =
serde_json::from_value(notification.params)?;
let uri = parameters.text_document.uri;
documents.remove(uri.as_str());
publish(connection, uri, Vec::new(), None)?;
publish_open_documents(connection, documents)?;
}
_ => {}
}
Ok(())
}
fn publish_open_documents(
connection: &Connection,
documents: &BTreeMap<String, OpenDocument>,
) -> LspResult<()> {
for value in documents.keys() {
let uri = value.parse::<Uri>()?;
publish_document(connection, &uri, documents)?;
}
Ok(())
}
fn publish_document(
connection: &Connection,
uri: &Uri,
documents: &BTreeMap<String, OpenDocument>,
) -> LspResult<()> {
let Some(document) = documents.get(uri.as_str()) else {
return Ok(());
};
let diagnostics = project_diagnostics(uri, document, documents).unwrap_or_else(|| {
let source = SourceFile::new(uri.as_str().to_owned(), document.text.clone());
let output = compile_source(source);
diagnostics_to_lsp(uri, &document.text, &output.diagnostics)
});
publish(connection, uri.clone(), diagnostics, Some(document.version))
}
fn project_diagnostics(
uri: &Uri,
document: &OpenDocument,
documents: &BTreeMap<String, OpenDocument>,
) -> Option<Vec<LspDiagnostic>> {
let document_path = file_uri_path(uri)?;
let canonical_document = document_path.canonicalize().ok()?;
let target = match crate::project::resolve_document_target(&canonical_document) {
Ok(target) => target,
Err(message) => {
let diagnostic = TessDiagnostic::error("M0100", message, Span::default());
return Some(diagnostics_to_lsp(uri, &document.text, &[diagnostic]));
}
};
let mut overrides = BTreeMap::new();
for (open_uri, open_document) in documents {
let Ok(open_uri) = open_uri.parse::<Uri>() else {
continue;
};
let Some(path) = file_uri_path(&open_uri) else {
continue;
};
let Ok(path) = path.canonicalize() else {
continue;
};
overrides.insert(path, open_document.text.clone());
}
let output = match compile_project_with_dependency_graph_and_overrides(
&target.entry,
&target.root,
&target.dependencies,
&overrides,
) {
Ok(output) => output,
Err(error) => {
let diagnostic = TessDiagnostic::error("M0100", error.to_string(), Span::default());
return Some(diagnostics_to_lsp(uri, &document.text, &[diagnostic]));
}
};
if !output.files.iter().any(|path| path == &canonical_document) {
let diagnostic = TessDiagnostic::warning(
"M0101",
"this file is not reachable from the project's manifest entry",
Span::default(),
)
.with_help("add an import from `src/main.tes` or another reachable file");
return Some(diagnostics_to_lsp(uri, &document.text, &[diagnostic]));
}
let expected_name = canonical_document.display().to_string();
let diagnostics: Vec<_> = output
.compile
.diagnostics
.iter()
.filter(|diagnostic| output.source.name_at(diagnostic.primary.start) == expected_name)
.filter_map(|diagnostic| local_diagnostic(&output.source, diagnostic, &expected_name))
.collect();
Some(diagnostics_to_lsp(uri, &document.text, &diagnostics))
}
fn local_diagnostic(
source: &SourceFile,
diagnostic: &TessDiagnostic,
expected_name: &str,
) -> Option<TessDiagnostic> {
let mut diagnostic = diagnostic.clone();
diagnostic.primary = source.local_span(diagnostic.primary)?;
diagnostic.labels = diagnostic
.labels
.into_iter()
.filter_map(|mut label| {
if source.name_at(label.span.start) != expected_name {
return None;
}
label.span = source.local_span(label.span)?;
Some(label)
})
.collect();
Some(diagnostic)
}
fn file_uri_path(uri: &Uri) -> Option<PathBuf> {
let raw = uri.as_str().strip_prefix("file://")?;
let path = if let Some(path) = raw.strip_prefix("localhost/") {
format!("/{path}")
} else if raw.starts_with('/') {
raw.to_owned()
} else {
return None;
};
let decoded = percent_decode(path.as_bytes())?;
let path = String::from_utf8(decoded).ok()?;
Some(Path::new(&path).to_path_buf())
}
fn percent_decode(value: &[u8]) -> Option<Vec<u8>> {
let mut decoded = Vec::with_capacity(value.len());
let mut cursor = 0;
while cursor < value.len() {
if value[cursor] != b'%' {
decoded.push(value[cursor]);
cursor += 1;
continue;
}
let high = hex(value.get(cursor + 1).copied()?)?;
let low = hex(value.get(cursor + 2).copied()?)?;
decoded.push(high * 16 + low);
cursor += 3;
}
Some(decoded)
}
const fn hex(value: u8) -> Option<u8> {
match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
b'A'..=b'F' => Some(value - b'A' + 10),
_ => None,
}
}
fn publish(
connection: &Connection,
uri: Uri,
diagnostics: Vec<LspDiagnostic>,
version: Option<i32>,
) -> LspResult<()> {
let parameters = PublishDiagnosticsParams::new(uri, diagnostics, version);
connection
.sender
.send(Message::Notification(ServerNotification::new(
PublishDiagnostics::METHOD.to_owned(),
parameters,
)))?;
Ok(())
}
fn diagnostics_to_lsp(uri: &Uri, text: &str, diagnostics: &[TessDiagnostic]) -> Vec<LspDiagnostic> {
let index = LineIndex::new(text);
diagnostics
.iter()
.map(|diagnostic| {
let related_information = (!diagnostic.labels.is_empty()).then(|| {
diagnostic
.labels
.iter()
.map(|label| DiagnosticRelatedInformation {
location: Location {
uri: uri.clone(),
range: index.range(label.span),
},
message: label.message.clone(),
})
.collect()
});
let message = diagnostic.help.as_ref().map_or_else(
|| diagnostic.message.clone(),
|help| format!("{}\nhelp: {help}", diagnostic.message),
);
LspDiagnostic {
range: index.range(diagnostic.primary),
severity: Some(match diagnostic.severity {
Severity::Error => DiagnosticSeverity::ERROR,
Severity::Warning => DiagnosticSeverity::WARNING,
}),
code: Some(NumberOrString::String(diagnostic.code.clone())),
code_description: None,
source: Some("tess".to_owned()),
message,
related_information,
tags: None,
data: diagnostic.help.as_ref().map(|help| json!({ "help": help })),
}
})
.collect()
}
#[derive(Debug)]
struct LineIndex<'a> {
text: &'a str,
line_starts: Vec<usize>,
line_ends: Vec<usize>,
}
impl<'a> LineIndex<'a> {
fn new(text: &'a str) -> Self {
let mut line_starts = vec![0];
let mut line_ends = Vec::new();
let bytes = text.as_bytes();
let mut offset = 0;
while offset < bytes.len() {
match bytes[offset] {
b'\r' => {
line_ends.push(offset);
offset += if bytes.get(offset + 1) == Some(&b'\n') {
2
} else {
1
};
line_starts.push(offset);
}
b'\n' => {
line_ends.push(offset);
offset += 1;
line_starts.push(offset);
}
_ => offset += 1,
}
}
line_ends.push(text.len());
Self {
text,
line_starts,
line_ends,
}
}
fn range(&self, span: Span) -> Range {
let start = self.char_boundary_at_or_before(span.start);
let end = self.char_boundary_at_or_before(span.end.max(start));
Range::new(self.position(start), self.position(end.max(start)))
}
fn full_range(&self) -> Range {
Range::new(Position::new(0, 0), self.position(self.text.len()))
}
fn position(&self, byte: usize) -> Position {
let byte = self.char_boundary_at_or_before(byte);
let line = self
.line_starts
.partition_point(|line_start| *line_start <= byte)
.saturating_sub(1);
let line_start = self.line_starts[line];
let content_byte = byte.min(self.line_ends[line]);
let character = self.text[line_start..content_byte].encode_utf16().count();
Position::new(saturating_u32(line), saturating_u32(character))
}
fn offset(&self, position: Position) -> Option<usize> {
let line = usize::try_from(position.line).ok()?;
let target = usize::try_from(position.character).ok()?;
let start = *self.line_starts.get(line)?;
let end = *self.line_ends.get(line)?;
let mut utf16 = 0;
for (relative, character) in self.text[start..end].char_indices() {
if utf16 == target {
return Some(start + relative);
}
utf16 += character.len_utf16();
if utf16 > target {
return None;
}
}
(utf16 == target).then_some(end)
}
fn char_boundary_at_or_before(&self, byte: usize) -> usize {
let mut byte = byte.min(self.text.len());
while !self.text.is_char_boundary(byte) {
byte = byte.saturating_sub(1);
}
byte
}
}
fn saturating_u32(value: usize) -> u32 {
u32::try_from(value).unwrap_or(u32::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
use lsp_server::ResponseKind;
use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_PROJECT: AtomicU64 = AtomicU64::new(0);
fn complete_at_marker(marked: &str) -> Vec<CompletionItem> {
let sentinel = "<caret>";
let cursor = marked
.find(sentinel)
.expect("completion fixture needs a marker");
assert_eq!(
marked.rfind(sentinel),
Some(cursor),
"completion fixture must have exactly one marker"
);
let text = marked.replacen(sentinel, "", 1);
let uri = Uri::from_str("file:///completion-fixture.tes").unwrap();
let mut documents = BTreeMap::new();
documents.insert(
uri.as_str().to_owned(),
OpenDocument {
text: text.clone(),
version: 1,
},
);
completion_items(
&uri,
&documents[uri.as_str()],
LineIndex::new(&text).position(cursor),
&documents,
)
.unwrap()
}
fn labels(items: &[CompletionItem]) -> Vec<&str> {
items.iter().map(|item| item.label.as_str()).collect()
}
#[test]
fn byte_offsets_are_mapped_to_zero_based_utf16_positions() {
let text = "a😀한\nz";
let index = LineIndex::new(text);
assert_eq!(index.position(0), Position::new(0, 0));
assert_eq!(index.position(1), Position::new(0, 1));
assert_eq!(index.position(5), Position::new(0, 3));
assert_eq!(index.position(8), Position::new(0, 4));
assert_eq!(index.position(9), Position::new(1, 0));
assert_eq!(index.position(text.len()), Position::new(1, 1));
}
#[test]
fn full_range_preserves_a_trailing_empty_line_and_crlf_columns() {
let index = LineIndex::new("a😀한\r\nb");
assert_eq!(index.position(8), Position::new(0, 4));
assert_eq!(index.position(9), Position::new(0, 4));
assert_eq!(index.position(10), Position::new(1, 0));
assert_eq!(
index.full_range(),
Range::new(Position::new(0, 0), Position::new(1, 1))
);
let trailing_newline = LineIndex::new("a\r\n");
assert_eq!(
trailing_newline.full_range(),
Range::new(Position::new(0, 0), Position::new(1, 0))
);
}
#[test]
fn utf16_positions_map_back_to_byte_offsets_without_splitting_surrogates() {
let text = "a😀한\r\nb";
let index = LineIndex::new(text);
for byte in [0, 1, 5, 8, 10, text.len()] {
let position = index.position(byte);
assert_eq!(index.offset(position), Some(byte));
}
assert_eq!(index.offset(Position::new(0, 2)), None);
assert_eq!(index.offset(Position::new(2, 0)), None);
}
#[test]
fn completion_sites_recognize_exact_and_prefixed_korean_contexts() {
let exact_member = " when s.";
assert_eq!(
completion_site(exact_member, exact_member.len()),
Some(CompletionSite::Member {
receiver: MemberReceiver::Name("s".to_owned()),
prefix: String::new(),
replace: Span::new(exact_member.len(), exact_member.len()),
})
);
let prefixed_member = " when s.결";
assert_eq!(
completion_site(prefixed_member, prefixed_member.len()),
Some(CompletionSite::Member {
receiver: MemberReceiver::Name("s".to_owned()),
prefix: "결".to_owned(),
replace: Span::new(prefixed_member.len() - "결".len(), prefixed_member.len()),
})
);
let exact_source = " source ";
let Some(CompletionSite::Word {
prefix, replace, ..
}) = completion_site(exact_source, exact_source.len())
else {
panic!("expected a word completion site")
};
assert_eq!(prefix, "");
assert_eq!(replace, Span::new(exact_source.len(), exact_source.len()));
let prefixed_source = " source 강";
let Some(CompletionSite::Word {
prefix, replace, ..
}) = completion_site(prefixed_source, prefixed_source.len())
else {
panic!("expected a prefixed word completion site")
};
assert_eq!(prefix, "강");
assert_eq!(
replace,
Span::new(prefixed_source.len() - "강".len(), prefixed_source.len())
);
assert!(matches!(
completion_site("source ", "source ".len()),
Some(CompletionSite::Word { .. })
));
assert_eq!(completion_site(" # source ", " # source ".len()), None);
let derive_member = " when 그대로(s).";
assert_eq!(
completion_site(derive_member, derive_member.len()),
Some(CompletionSite::Member {
receiver: MemberReceiver::DeriveCall("그대로".to_owned()),
prefix: String::new(),
replace: Span::new(derive_member.len(), derive_member.len()),
})
);
}
#[test]
fn import_paths_complete_relative_tess_files_and_directories() {
let id = NEXT_PROJECT.fetch_add(1, Ordering::Relaxed);
let directory =
std::env::temp_dir().join(format!("tess-lsp-import-{}-{id}", std::process::id()));
let rules = directory.join("rules");
std::fs::create_dir_all(&rules).unwrap();
std::fs::write(rules.join("policy.tes"), "module m\n").unwrap();
std::fs::write(rules.join("notes.txt"), "not Tess\n").unwrap();
let main = directory.join("main.tes");
std::fs::write(&main, "module m\n").unwrap();
let uri = Uri::from_str(&format!("file://{}", main.display())).unwrap();
for (text, expected) in [
("module m\nimport \"ru", vec!["rules/"]),
("module m\nimport \"rules/", vec!["rules/policy.tes"]),
] {
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let mut documents = BTreeMap::new();
documents.insert(uri.as_str().to_owned(), document);
let items = completion_items(
&uri,
&documents[uri.as_str()],
LineIndex::new(text).position(text.len()),
&documents,
)
.unwrap();
assert_eq!(labels(&items), expected);
}
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn declaration_and_rule_clause_keywords_complete_partial_words() {
let items = complete_at_marker("mo<caret>");
assert_eq!(labels(&items), ["module"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::KEYWORD));
assert_eq!(items[0].detail.as_deref(), Some("start a Tess module"));
assert_eq!(
items[0].text_edit,
Some(CompletionTextEdit::Edit(TextEdit::new(
Range::new(Position::new(0, 0), Position::new(0, 2)),
"module".to_owned(),
)))
);
let items = complete_at_marker("module m\n\nru<caret>");
assert_eq!(labels(&items), ["rule"]);
assert_eq!(items[0].detail.as_deref(), Some("declare a policy rule"));
let base = "module m\nentity 학생 {\n 점수: Int,\n}\nrule 현재(학생 s):\n";
let items = complete_at_marker(&format!("{base} w<caret>"));
assert_eq!(labels(&items), ["when"]);
assert_eq!(items[0].detail.as_deref(), Some("rule condition"));
let items = complete_at_marker(&format!("{base} when true\n t<caret>"));
assert_eq!(labels(&items), ["then"]);
let items = complete_at_marker(&format!(
"{base} when true\n then 결과(s) = true\n s<caret>"
));
assert_eq!(labels(&items), ["source"]);
let items = complete_at_marker(&format!(
"{base} when true\n then 결과(s) = true\n <caret>"
));
assert_eq!(labels(&items), ["then", "source"]);
let items = complete_at_marker(&format!(
"{base} when true\n then 결과(s) = true\n source 규정\n <caret>"
));
assert_eq!(labels(&items), ["source"]);
let items = complete_at_marker("module m\nentity E {\n x: Int,\n}\n<caret>");
assert!(!labels(&items).contains(&"import"));
}
#[test]
fn rule_effect_source_and_compatible_override_targets_are_contextual() {
let declarations = concat!(
"module m\n",
"source 학칙 {\n title: \"학칙\",\n}\n",
"entity 학생 {\n 점수: Int,\n}\n",
"entity 교사 {\n 이름: String,\n}\n",
"enum 등급 { A, F }\n",
"decision 최종등급(학생 s) -> 등급\n",
"rule 기본규칙(학생 s):\n when true\n then 최종등급(s) = F\n",
"rule 다른타입(교사 t):\n when true\n then 최종등급(t) = F\n",
);
let items = complete_at_marker(&format!(
"{declarations}rule 현재규칙(학생 s):\n when true\n then <caret>"
));
assert_eq!(labels(&items), ["override", "최종등급"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::KEYWORD));
assert_eq!(items[1].kind, Some(CompletionItemKind::FUNCTION));
assert_eq!(
items[1].detail.as_deref(),
Some("decision · (학생 s) -> 등급")
);
let items = complete_at_marker(&format!(
"{declarations}rule 현재규칙(학생 s):\n when true\n then override 기<caret>"
));
assert_eq!(labels(&items), ["기본규칙"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::REFERENCE));
assert_eq!(items[0].detail.as_deref(), Some("rule · (학생 s)"));
let items = complete_at_marker(&format!(
"{declarations}rule 현재규칙(학생 s):\n when true\n source 학<caret>"
));
assert_eq!(labels(&items), ["학칙"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::REFERENCE));
assert_eq!(items[0].detail.as_deref(), Some("source · 학칙"));
}
#[test]
fn decision_names_and_arguments_follow_available_scope_types() {
let declarations = concat!(
"module m\n",
"entity 학생 { 점수: Int, }\n",
"entity 교사 { 이름: String, }\n",
"entity 교실 { 호수: Int, }\n",
"decision 학생판정(학생 s) -> Bool\n",
"decision 역순배정(교사 t, 학생 s) -> Bool\n",
"decision 교실판정(교실 c) -> Bool\n",
);
let items = complete_at_marker(&format!(
"{declarations}rule 배정(학생 s, 교사 t):\n when true\n then <caret>"
));
let names = labels(&items);
assert!(names.contains(&"학생판정"));
assert!(names.contains(&"역순배정"));
assert!(!names.contains(&"교실판정"));
let items = complete_at_marker(&format!(
"{declarations}rule 배정(학생 s, 교사 t):\n when true\n then 역순배정(<caret>"
));
assert_eq!(labels(&items), ["t"]);
let items = complete_at_marker(&format!(
"{declarations}rule 배정(학생 s, 교사 t):\n when true\n then 역순배정(t, <caret>"
));
assert_eq!(labels(&items), ["s"]);
let items = complete_at_marker(&format!(
"{declarations}case 배정예시:\n let s = 학생 {{ 점수: 100, }}\n let t = 교사 {{ 이름: \"담임\", }}\n expect <caret>"
));
let names = labels(&items);
assert!(names.contains(&"학생판정"));
assert!(names.contains(&"역순배정"));
assert!(!names.contains(&"교실판정"));
let items = complete_at_marker(&format!(
"{declarations}invariant 배정완전성:\n for all s: 학생, t: 교사\n exactly one <caret>"
));
let names = labels(&items);
assert!(names.contains(&"학생판정"));
assert!(names.contains(&"역순배정"));
assert!(!names.contains(&"교실판정"));
}
#[test]
fn override_targets_exclude_duplicates_and_transitive_cycles() {
let items = complete_at_marker(concat!(
"module m\n",
"entity 입력 { 값: Int, }\n",
"decision 판정(입력 x) -> Bool\n",
"rule 안전(입력 x):\n when true\n then 판정(x) = true\n",
"rule 중복(입력 x):\n when true\n then 판정(x) = true\n",
"rule 순환A(입력 x):\n when true\n then override 순환B\n",
"rule 순환B(입력 x):\n when true\n then override 현재\n",
"rule 현재(입력 x):\n",
" when true\n",
" then 판정(x) = true\n",
" then override 중복\n",
" then override <caret>",
));
assert_eq!(labels(&items), ["안전"]);
}
#[test]
fn rule_expression_starters_include_scope_callables_builtins_and_values() {
let text = concat!(
"module m\n",
"enum 등급 { A, F }\n",
"entity 학생 {\n 점수: Int,\n}\n",
"derive 통과가능(학생 s) -> Bool: s.점수 >= 60\n",
"decision 최종등급(학생 s) -> 등급\n",
"rule 현재(학생 s):\n",
" when <caret>",
);
let items = complete_at_marker(text);
let expression_labels = labels(&items);
for expected in [
"not",
"true",
"false",
"unknown",
"s",
"통과가능",
"date",
"abs",
"max",
] {
assert!(
expression_labels.contains(&expected),
"missing {expected} in {expression_labels:?}"
);
}
let variable = items.iter().find(|item| item.label == "s").unwrap();
assert_eq!(variable.kind, Some(CompletionItemKind::VARIABLE));
assert_eq!(variable.detail.as_deref(), Some("variable · 학생"));
let items = complete_at_marker(&text.replace("when <caret>", "when true <caret>"));
assert_eq!(labels(&items), ["and", "or", "=", "!="]);
}
#[test]
fn case_completions_cover_clauses_entities_fields_values_and_expectations() {
let declarations = concat!(
"module m\n",
"enum 등급 { A, F }\n",
"entity 학생 {\n 총점: Int range 0..100,\n 결석: Int,\n 등급값: 등급,\n}\n",
"decision 최종등급(학생 s) -> 등급\n",
);
let items = complete_at_marker(&format!("{declarations}case 예시:\n le<caret>"));
assert_eq!(labels(&items), ["let"]);
assert_eq!(items[0].detail.as_deref(), Some("bind an entity input"));
let items = complete_at_marker(&format!("{declarations}case 예시:\n let s = <caret>"));
assert_eq!(labels(&items), ["학생"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::CLASS));
let items = complete_at_marker(&format!(
"{declarations}case 예시:\n let s = 학생 {{\n 총<caret>"
));
assert_eq!(labels(&items), ["총점"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::FIELD));
assert_eq!(items[0].detail.as_deref(), Some("학생 · Int range 0..100"));
let items = complete_at_marker(&format!(
"{declarations}case 예시:\n let s = 학생 {{\n 총점: 100,\n 결석: 0,\n }}\n expect <caret>"
));
assert_eq!(labels(&items), ["최종등급"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::FUNCTION));
let items = complete_at_marker(&format!(
"{declarations}case 예시:\n let s = 학생 {{\n 등급값: A<caret>"
));
assert_eq!(labels(&items), ["A"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::ENUM_MEMBER));
let items = complete_at_marker(&format!(
"{declarations}case 예시:\n let s = 학생 {{ 총점: 100, 결석: 0, 등급값: A, }}\n expect 최종등급(s) = A\n <caret>"
));
assert_eq!(labels(&items), ["expect"]);
}
#[test]
fn case_values_are_type_directed_and_required_fields_sort_first() {
let declarations = concat!(
"module m\n",
"enum 왼쪽 { 공통, 왼쪽만 }\n",
"enum 오른쪽 { 공통, 오른쪽만 }\n",
"entity 입력 {\n",
" 선택사항: 왼쪽?,\n",
" 활성: Bool,\n",
" 결과: 오른쪽,\n",
"}\n",
);
let items = complete_at_marker(&format!(
"{declarations}case 예시:\n let x = 입력 {{\n <caret>"
));
assert_eq!(labels(&items), ["활성", "결과", "선택사항"]);
let items = complete_at_marker(&format!(
"{declarations}case 예시:\n let x = 입력 {{\n 결과: <caret>"
));
let names = labels(&items);
assert!(names.contains(&"공통"));
assert!(names.contains(&"오른쪽만"));
assert!(!names.contains(&"왼쪽만"));
assert!(items.iter().all(|item| {
item.detail
.as_deref()
.is_some_and(|detail| detail.contains("오른쪽"))
}));
let items = complete_at_marker(&format!(
"{declarations}case 예시:\n let x = 입력 {{\n 선택사항: <caret>"
));
let names = labels(&items);
assert!(names.contains(&"공통"));
assert!(names.contains(&"왼쪽만"));
assert!(names.contains(&"unknown"));
assert!(!names.contains(&"오른쪽만"));
let items = complete_at_marker(&format!("{declarations}rule r(입력 x):\n when <caret>"));
assert!(!labels(&items).contains(&"공통"));
let temporal = concat!(
"module m\n",
"entity 일정 { 시작일: Date, 유예: Duration?, }\n",
"case 예시:\n",
" let x = 일정 {\n",
);
let items = complete_at_marker(&format!("{temporal} 시작일: <caret>"));
assert_eq!(labels(&items), ["date"]);
let items = complete_at_marker(&format!("{temporal} 유예: <caret>"));
assert_eq!(labels(&items), ["days", "hours", "minutes", "unknown"]);
}
#[test]
fn expression_values_and_derive_arguments_follow_expected_types() {
let declarations = concat!(
"module m\n",
"enum 등급 { 일반, 우수 }\n",
"enum 상태 { 일반, 휴면 }\n",
"entity 고객 { 등급값: 등급, 활성: Bool?, 금액: Int, }\n",
"entity 교사 { 이름: String, }\n",
"derive 현재등급(고객 c) -> 등급: c.등급값\n",
"derive 그대로(고객 c) -> 고객: c\n",
);
let items = complete_at_marker(&format!(
"{declarations}derive 기본등급(고객 c) -> 등급: <caret>"
));
let names = labels(&items);
assert_eq!(names.len(), 2);
assert!(names.contains(&"일반"));
assert!(names.contains(&"우수"));
assert!(!names.contains(&"휴면"));
for condition in [
"c.등급값 = <caret>",
"현재등급(c) = <caret>",
"그대로(c).등급값 = <caret>",
] {
let items = complete_at_marker(&format!(
"{declarations}rule 정책(고객 c):\n when {condition}"
));
let names = labels(&items);
assert_eq!(names.len(), 2);
assert!(names.contains(&"일반"));
assert!(names.contains(&"우수"));
assert!(!names.contains(&"휴면"));
}
let items = complete_at_marker(&format!(
"{declarations}rule 정책(고객 c):\n when c.활성 = <caret>"
));
assert_eq!(labels(&items), ["true", "false", "unknown"]);
let items = complete_at_marker(&format!(
"{declarations}rule 정책(고객 c, 교사 t):\n when 현재등급(<caret>"
));
assert_eq!(labels(&items), ["c"]);
let items = complete_at_marker(&format!(
"{declarations}rule 정책(고객 c):\n when c.등급값 <caret>"
));
assert_eq!(labels(&items), ["=", "!="]);
let items = complete_at_marker(&format!(
"{declarations}rule 정책(고객 c):\n when c.금액 <caret>"
));
assert_eq!(
labels(&items),
["=", "!=", ">", ">=", "<", "<=", "+", "-", "*", "/"]
);
let items = complete_at_marker(&format!(
"{declarations}rule 정책(고객 c):\n when c.금액 >= 10 <caret>"
));
assert_eq!(labels(&items), ["and", "or"]);
let items = complete_at_marker(
"module m\nderive 날짜(String text, Int count, Decimal rate, Bool flag) -> Date: date(<caret>",
);
assert_eq!(labels(&items), ["text"]);
let items = complete_at_marker(
"module m\nderive 큰값(String text, Int count, Decimal rate, Bool flag) -> Decimal: max(<caret>",
);
assert_eq!(labels(&items), ["count", "rate"]);
}
#[test]
fn already_used_metadata_sources_and_case_fields_are_not_reoffered() {
let items = complete_at_marker(concat!(
"module m\n",
"source 규정 {\n",
" title: \"규정\",\n",
" section: \"1\",\n",
" <caret>",
));
assert_eq!(labels(&items), ["version", "uri"]);
let declarations = concat!(
"module m\n",
"source 첫째 { title: \"첫째\" }\n",
"source 둘째 { title: \"둘째\" }\n",
"entity 입력 { 필수A: Bool, 선택: Bool?, 필수B: Bool, }\n",
"decision 판정(입력 x) -> Bool\n",
);
let items = complete_at_marker(&format!(
"{declarations}rule r(입력 x):\n when true\n then 판정(x) = true\n source 첫째\n source <caret>"
));
assert_eq!(labels(&items), ["둘째"]);
let items = complete_at_marker(&format!(
"{declarations}case c:\n let x = 입력 {{\n 필수A: true,\n <caret>"
));
assert_eq!(labels(&items), ["필수B", "선택"]);
}
#[test]
fn invariant_completions_follow_quantifier_assertion_and_implication_states() {
let declarations = concat!(
"module m\n",
"enum 등급 { A, F }\n",
"entity 학생 {\n 점수: Int,\n}\n",
"decision 최종등급(학생 s) -> 등급\n",
);
let items = complete_at_marker(&format!("{declarations}invariant 완전성:\n fo<caret>"));
assert_eq!(labels(&items), ["for all", "for some"]);
assert_eq!(
items[0].detail.as_deref(),
Some("require every input to satisfy the invariant")
);
assert_eq!(
items[1].detail.as_deref(),
Some("require at least one input to satisfy the invariant")
);
let items = complete_at_marker(&format!("{declarations}invariant 완전성:\n for <caret>"));
assert_eq!(labels(&items), ["all", "some"]);
let items = complete_at_marker(&format!(
"{declarations}invariant 완전성:\n for all s: <caret>"
));
assert_eq!(labels(&items), ["학생"]);
let items = complete_at_marker(&format!(
"{declarations}invariant 존재성:\n for some s: <caret>"
));
assert_eq!(labels(&items), ["학생"]);
let items = complete_at_marker(&format!(
"{declarations}invariant 완전성:\n for all s: 학생\n ex<caret>"
));
assert_eq!(labels(&items), ["exactly one"]);
assert_eq!(
items[0].detail.as_deref(),
Some("require exactly one decision result")
);
let items = complete_at_marker(&format!(
"{declarations}invariant 완전성:\n for all s: 학생\n exactly one <caret>"
));
assert_eq!(labels(&items), ["최종등급"]);
let items = complete_at_marker(&format!(
"{declarations}invariant 완전성:\n for all s: 학생\n if <caret>"
));
assert!(labels(&items).contains(&"s"));
let items = complete_at_marker(&format!(
"{declarations}invariant 완전성:\n for all s: 학생\n if s.점수 >= 60\n th<caret>"
));
assert_eq!(labels(&items), ["then"]);
let items = complete_at_marker(&format!(
"{declarations}invariant 완전성:\n for all s: 학생\n if s.점수 >= 60\n then <caret>"
));
assert_eq!(labels(&items), ["최종등급"]);
}
#[test]
fn type_and_source_metadata_completions_are_restricted_to_valid_positions() {
let declarations = concat!(
"module m\n",
"enum 등급 { A, F }\n",
"entity 학생 {\n 점수: Int,\n}\n",
);
let items = complete_at_marker(&format!("{declarations}rule r(<caret>"));
assert_eq!(labels(&items), ["학생"]);
let items = complete_at_marker(&format!("{declarations}decision d(학생 s) -> <caret>"));
let type_labels = labels(&items);
assert!(type_labels.contains(&"Bool"));
assert!(type_labels.contains(&"등급"));
assert!(!type_labels.contains(&"학생"));
let items = complete_at_marker("module m\nentity 학생 {\n 점수: <caret>");
assert!(labels(&items).contains(&"Int"));
let items = complete_at_marker("module m\nsource 규정 {\n ti<caret>");
assert_eq!(labels(&items), ["title"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::FIELD));
let items = complete_at_marker("module m\nentity 학생 { 점수: <caret>");
assert!(labels(&items).contains(&"Int"));
let items = complete_at_marker("module m\nentity 학생 { 점수: Int <caret>");
assert_eq!(labels(&items), ["?", "range"]);
let items = complete_at_marker("module m\nsource 규정 { <caret>");
assert_eq!(labels(&items), ["title", "section", "version", "uri"]);
}
#[test]
fn completion_is_suppressed_in_strings_and_bodyless_declarations() {
let items =
complete_at_marker("module m\nsource 규정 {\n title: \"아직 닫히지 않음<caret>");
assert!(items.is_empty());
let declarations = concat!(
"module m\n",
"entity 입력 { 값: Int, }\n",
"decision 판정(입력 x) -> Bool\n",
);
let items = complete_at_marker(&format!(
"{declarations}rule r(입력 x):\n when true\n then 판정(x) <caret>"
));
assert_eq!(labels(&items), ["="]);
assert_eq!(items[0].kind, Some(CompletionItemKind::OPERATOR));
let items = complete_at_marker(&format!(
"{declarations}case c:\n let x = 입력 {{ 값: 1, }}\n expect 판정(x) <caret>"
));
assert_eq!(labels(&items), ["=", "!="]);
let items = complete_at_marker(&format!(
"{declarations}invariant i:\n for all x: 입력\n if true\n then 판정(x) <caret>"
));
assert_eq!(labels(&items), ["=", "!="]);
let items = complete_at_marker(&format!("{declarations} x.<caret>"));
assert!(items.is_empty());
let items = complete_at_marker(concat!(
"module m\n",
"entity 입력 { 값: Int, }\n",
"derive 값(입력 x) -> Int: x.값\n",
" x.<caret>",
));
assert!(items.is_empty());
}
#[test]
fn derive_return_types_include_entities_and_member_scope_stays_in_expression() {
let declarations = "module m\nentity 입력 { 값: Int, }\n";
let items = complete_at_marker(&format!("{declarations}derive 그대로(입력 x) -> <caret>"));
assert!(labels(&items).contains(&"입력"));
let items = complete_at_marker(&format!(
"{declarations}derive 값(입력 x) -> Int: x.<caret>"
));
assert_eq!(labels(&items), ["값"]);
let declarations = concat!(
"module m\n",
"entity 입력 { 값: Int, }\n",
"derive 그대로(입력 x) -> 입력: x\n",
);
let items = complete_at_marker(&format!(
"{declarations}derive 값(입력 x) -> Int: 그대로(x).<caret>"
));
assert_eq!(labels(&items), ["값"]);
}
#[test]
fn derive_call_arguments_cardinality_tails_and_numeric_ranges_complete() {
let declarations = concat!(
"module m\n",
"enum 등급 { A, F }\n",
"entity 학생 {\n 점수: Int,\n}\n",
"decision 최종등급(학생 s) -> 등급\n",
);
let items = complete_at_marker(&format!(
"{declarations}derive 그대로(학생 s) -> 학생: s<caret>"
));
assert_eq!(labels(&items), ["s"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::VARIABLE));
for body in [
"rule r(학생 s):\n when true\n then 최종등급(<caret>",
"case c:\n let s = 학생 {\n 점수: 100,\n }\n expect 최종등급(<caret>",
"invariant i:\n for all s: 학생\n exactly one 최종등급(<caret>",
"invariant i:\n for all s: 학생\n if true\n then 최종등급(<caret>",
] {
let items = complete_at_marker(&format!("{declarations}{body}"));
assert!(labels(&items).contains(&"s"), "body: {body}");
}
let items = complete_at_marker(&format!(
"{declarations}decision 선택(학생 s) -> 등급 exactly <caret>"
));
assert_eq!(labels(&items), ["one"]);
let items = complete_at_marker(&format!(
"{declarations}decision 선택(학생 s) -> 등급 zero <caret>"
));
assert_eq!(labels(&items), ["or one"]);
let items = complete_at_marker("module m\nentity 학생 {\n 점수: Int ra<caret>");
assert_eq!(labels(&items), ["range"]);
assert_eq!(items[0].detail.as_deref(), Some("numeric field constraint"));
}
#[test]
fn canonical_field_modifiers_and_cardinality_follow_parser_states() {
let items = complete_at_marker("module m\nentity 입력 {\n 활성: Bool <caret>");
assert_eq!(labels(&items), ["?"]);
let items = complete_at_marker("module m\nentity 입력 {\n 상태: String <caret>");
assert_eq!(labels(&items), ["?", "domain"]);
let items = complete_at_marker("module m\nentity 입력 {\n 비율: Decimal <caret>");
assert_eq!(labels(&items), ["?", "range", "domain"]);
let items = complete_at_marker("module m\nentity 입력 {\n 점수: Int <caret>");
assert_eq!(labels(&items), ["?", "range"]);
let items = complete_at_marker("module m\nentity 입력 {\n 점수: Int optional <caret>");
assert_eq!(labels(&items), ["range"]);
let items =
complete_at_marker("module m\nentity 입력 {\n 점수: Int range -10..100 <caret>");
assert_eq!(labels(&items), ["?"]);
let items = complete_at_marker("module m\nentity 입력 {\n 점수: Int range <caret>");
assert!(items.is_empty());
let declarations = concat!(
"module m\n",
"entity 입력 { 값: Int, }\n",
"decision 결과(입력 x) -> Bool <caret>",
);
let items = complete_at_marker(declarations);
assert_eq!(labels(&items), ["exactly one", "zero or one", "many"]);
let items =
complete_at_marker(&declarations.replace("Bool <caret>", "Bool cardinality <caret>"));
assert_eq!(labels(&items), ["exactly one", "zero or one", "many"]);
let items = complete_at_marker(&declarations.replace(
"decision 결과(입력 x) -> Bool <caret>",
"invariant 유일성:\n for all x: 입력\n <caret>",
));
assert_eq!(labels(&items), ["exactly one", "zero or one", "many", "if"]);
}
#[test]
fn finite_domain_values_complete_in_cases() {
let declarations = concat!(
"module m\n",
"entity 입력 { 상태: String domain {\"draft\", \"approved\"}, 비율: Decimal domain {0.05, 0.10}, }\n",
);
let items = complete_at_marker(&format!(
"{declarations}case 예시:\n let x = 입력 {{\n 상태: <caret>"
));
assert_eq!(labels(&items), ["\"approved\"", "\"draft\""]);
let items = complete_at_marker(&format!(
"{declarations}case 예시:\n let x = 입력 {{\n 비율: <caret>"
));
assert_eq!(labels(&items), ["0.05", "0.10"]);
}
#[test]
fn multiline_headers_derive_bodies_and_decision_calls_keep_their_context() {
let declarations = concat!(
"module m\n",
"entity 학생 { 점수: Int, }\n",
"entity 교사 { 이름: String, }\n",
"decision 판정(학생 s, 교사 t) -> Bool\n",
);
let items = complete_at_marker(&format!("{declarations}rule 정책(\n <caret>"));
assert_eq!(labels(&items), ["교사", "학생"]);
let items =
complete_at_marker(&format!("{declarations}derive 계산(\n 학생 s,\n <caret>"));
let names = labels(&items);
assert!(names.contains(&"Int"));
assert!(names.contains(&"교사"));
let items = complete_at_marker(&format!(
"{declarations}derive 그대로(학생 s) -> 학생:\n <caret>"
));
assert!(labels(&items).contains(&"s"));
for body in [
"rule 정책(학생 s, 교사 t):\n when true\n then 판정(\n <caret>",
"case 예시:\n let s = 학생 { 점수: 100, }\n let t = 교사 { 이름: \"담임\", }\n expect 판정(\n <caret>",
"invariant 완전성:\n for all s: 학생, t: 교사\n exactly one 판정(\n <caret>",
] {
let items = complete_at_marker(&format!("{declarations}{body}"));
assert_eq!(labels(&items), ["s"], "body: {body}");
}
}
#[test]
fn completion_context_does_not_leak_a_previous_declaration_scope() {
let items = complete_at_marker(concat!(
"module m\n",
"entity 학생 {\n 점수: Int,\n}\n",
"rule 이전(학생 s):\n when true\n then 결과(s) = true\n",
"entity 다음 {\n 값: Bool,\n}\n",
" wh<caret>",
));
assert!(items.is_empty());
let items = complete_at_marker("module m\n# wh<caret>");
assert!(items.is_empty());
}
#[test]
fn contextual_completion_filters_korean_sources_and_fields() {
let text = concat!(
"module 자동완성\n",
"source 강의계획서_제4조 {\n",
" title: \"강의계획서\",\n",
" section: \"제4조\",\n",
"}\n",
"source 학칙_제2조 {\n",
" title: \"학칙\",\n",
"}\n",
"entity 학생 {\n",
" 총점: Int range 0..100,\n",
" 결석횟수: Int? range 0..15,\n",
"}\n",
"decision 최종등급(학생 s) -> Int\n",
"rule 등급(학생 s):\n",
" when s.결\n",
" then 최종등급(s) = 0\n",
" source 강\n",
);
let uri = Uri::from_str("file:///completion-korean.tes").unwrap();
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let mut documents = BTreeMap::new();
documents.insert(uri.as_str().to_owned(), document);
let document = &documents[uri.as_str()];
let member_cursor = text.find("s.결").unwrap() + "s.결".len();
let member_items = completion_items(
&uri,
document,
LineIndex::new(text).position(member_cursor),
&documents,
)
.unwrap();
assert_eq!(member_items.len(), 1);
assert_eq!(member_items[0].label, "결석횟수");
assert_eq!(member_items[0].kind, Some(CompletionItemKind::FIELD));
assert_eq!(
member_items[0].detail.as_deref(),
Some("학생 · Int range 0..15 · optional")
);
assert_eq!(
member_items[0].text_edit,
Some(CompletionTextEdit::Edit(TextEdit::new(
LineIndex::new(text).range(Span::new(member_cursor - "결".len(), member_cursor)),
"결석횟수".to_owned()
)))
);
let source_cursor = text.rfind("source 강").unwrap() + "source 강".len();
let source_items = completion_items(
&uri,
document,
LineIndex::new(text).position(source_cursor),
&documents,
)
.unwrap();
assert_eq!(source_items.len(), 1);
assert_eq!(source_items[0].label, "강의계획서_제4조");
assert_eq!(source_items[0].kind, Some(CompletionItemKind::REFERENCE));
assert_eq!(
source_items[0].detail.as_deref(),
Some("source · 강의계획서 · 제4조")
);
}
#[test]
fn incomplete_rule_completion_uses_imported_project_declarations() {
let id = NEXT_PROJECT.fetch_add(1, Ordering::Relaxed);
let directory =
std::env::temp_dir().join(format!("tess-lsp-completion-{}-{id}", std::process::id()));
crate::project::scaffold(&directory, Some("lsp_project")).unwrap();
let model = directory.join("src/model/request.tes");
let rules = directory.join("src/rules/eligibility.tes");
let mut model_text = std::fs::read_to_string(&model).unwrap();
model_text.push_str(
"\nsource 강의계획서_제4조 {\n title: \"강의계획서\",\n section: \"제4조\",\n}\n",
);
let rules_text = std::fs::read_to_string(&rules)
.unwrap()
.replacen("when request.enabled", "when request.", 1)
.replacen(
"then eligibility(request) = Eligible",
"then eligibility(request) = Eligible\n source ",
1,
);
let rules_uri = Uri::from_str(&format!("file://{}", rules.display())).unwrap();
let mut documents = BTreeMap::new();
let model_uri = Uri::from_str(&format!("file://{}", model.display())).unwrap();
documents.insert(
model_uri.as_str().to_owned(),
OpenDocument {
text: model_text,
version: 2,
},
);
documents.insert(
rules_uri.as_str().to_owned(),
OpenDocument {
text: rules_text.clone(),
version: 1,
},
);
let document = &documents[rules_uri.as_str()];
let index = LineIndex::new(&rules_text);
let field_cursor = rules_text.find("request.\n").unwrap() + "request.".len();
let field_items = completion_items(
&rules_uri,
document,
index.position(field_cursor),
&documents,
)
.unwrap();
assert_eq!(field_items.len(), 1);
assert_eq!(field_items[0].label, "enabled");
assert_eq!(field_items[0].detail.as_deref(), Some("Request · Bool"));
let source_cursor = rules_text.find("source \n").unwrap() + "source ".len();
let source_items = completion_items(
&rules_uri,
document,
index.position(source_cursor),
&documents,
)
.unwrap();
assert_eq!(source_items.len(), 1);
assert_eq!(source_items[0].label, "강의계획서_제4조");
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn member_completion_uses_the_selected_receiver_in_multi_parameter_rules() {
let text = "module assignments\n\
entity 학생 {\n 총점: Int,\n}\n\
entity 교사 {\n 이름: String,\n}\n\
decision 배정(학생 s, 교사 t) -> Bool\n\
rule 배정규칙(학생 s, 교사 t):\n\
when s.\n\
then 배정(s, t) = true\n";
let uri = Uri::from_str("file:///multi-parameter-completion.tes").unwrap();
let mut documents = BTreeMap::new();
documents.insert(
uri.as_str().to_owned(),
OpenDocument {
text: text.to_owned(),
version: 1,
},
);
let cursor = text.find("s.\n").unwrap() + "s.".len();
let items = completion_items(
&uri,
&documents[uri.as_str()],
LineIndex::new(text).position(cursor),
&documents,
)
.unwrap();
assert_eq!(
items
.iter()
.map(|item| item.label.as_str())
.collect::<Vec<_>>(),
vec!["총점"]
);
}
#[test]
fn member_scope_does_not_leak_past_a_subsequent_declaration() {
let text = "module scopes\n\
entity 학생 {\n 총점: Int,\n}\n\
rule 이전규칙(학생 s):\n\
when true\n\
then 결과(s) = true\n\
entity 다음선언 {\n 값: Bool,\n}\n\
s.";
let uri = Uri::from_str("file:///scope-completion.tes").unwrap();
let mut documents = BTreeMap::new();
documents.insert(
uri.as_str().to_owned(),
OpenDocument {
text: text.to_owned(),
version: 1,
},
);
let items = completion_items(
&uri,
&documents[uri.as_str()],
LineIndex::new(text).position(text.len()),
&documents,
)
.unwrap();
assert!(items.is_empty());
}
#[test]
fn diagnostics_keep_severity_code_help_and_related_locations() {
let text = "module 😀\n";
let uri = Uri::from_str("file:///test.tes").unwrap();
let diagnostic = TessDiagnostic::warning(
"W0001",
"sample warning",
Span::new("module ".len(), "module 😀".len()),
)
.with_label(Span::new(0, 6), "related declaration")
.with_help("try another value");
let converted = diagnostics_to_lsp(&uri, text, &[diagnostic]);
let diagnostic = &converted[0];
assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
assert_eq!(
diagnostic.code,
Some(NumberOrString::String("W0001".to_owned()))
);
assert_eq!(diagnostic.source.as_deref(), Some("tess"));
assert_eq!(
diagnostic.range,
Range::new(Position::new(0, 7), Position::new(0, 9))
);
assert!(diagnostic.message.contains("help: try another value"));
assert_eq!(
diagnostic.data,
Some(json!({ "help": "try another value" }))
);
assert_eq!(diagnostic.related_information.as_ref().unwrap().len(), 1);
}
#[test]
fn project_diagnostics_resolve_imports_and_unsaved_partial_modules() {
let id = NEXT_PROJECT.fetch_add(1, Ordering::Relaxed);
let directory =
std::env::temp_dir().join(format!("tess lsp project-{}-{id}", std::process::id()));
crate::project::scaffold(&directory, Some("lsp_project")).unwrap();
let rules = directory.join("src/rules/eligibility.tes");
let text = std::fs::read_to_string(&rules).unwrap();
let uri_text = format!("file://{}", rules.display()).replace(' ', "%20");
let uri = Uri::from_str(&uri_text).unwrap();
assert_eq!(file_uri_path(&uri).unwrap(), rules);
let mut documents = BTreeMap::new();
documents.insert(
uri.as_str().to_owned(),
OpenDocument {
text: text.clone(),
version: 1,
},
);
let diagnostics = project_diagnostics(&uri, &documents[uri.as_str()], &documents).unwrap();
assert!(diagnostics.is_empty(), "{diagnostics:?}");
documents.get_mut(uri.as_str()).unwrap().text =
text.replace("request.enabled", "request.missing");
let diagnostics = project_diagnostics(&uri, &documents[uri.as_str()], &documents).unwrap();
assert!(!diagnostics.is_empty());
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("missing"))
);
assert!(
diagnostics
.iter()
.all(|diagnostic| diagnostic.range.start.line < 20)
);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn changing_a_model_republishes_dependent_open_rule_diagnostics() {
let id = NEXT_PROJECT.fetch_add(1, Ordering::Relaxed);
let directory =
std::env::temp_dir().join(format!("tess-lsp-dependents-{}-{id}", std::process::id()));
crate::project::scaffold(&directory, Some("lsp_project")).unwrap();
let model = directory.join("src/model/request.tes");
let rules = directory.join("src/rules/eligibility.tes");
let model_uri = Uri::from_str(&format!("file://{}", model.display())).unwrap();
let rules_uri = Uri::from_str(&format!("file://{}", rules.display())).unwrap();
let mut documents = BTreeMap::new();
documents.insert(
model_uri.as_str().to_owned(),
OpenDocument {
text: std::fs::read_to_string(&model)
.unwrap()
.replace("enabled: Bool", "active: Bool"),
version: 2,
},
);
documents.insert(
rules_uri.as_str().to_owned(),
OpenDocument {
text: std::fs::read_to_string(&rules).unwrap(),
version: 1,
},
);
let (server, client) = Connection::memory();
publish_open_documents(&server, &documents).unwrap();
let messages: Vec<_> = (0..documents.len())
.map(|_| client.receiver.recv().unwrap())
.collect();
let rule_publish = messages
.iter()
.find_map(|message| match message {
Message::Notification(notification)
if notification.params["uri"] == rules_uri.as_str() =>
{
Some(¬ification.params)
}
_ => None,
})
.expect("dependent rules document must be republished");
assert!(
!rule_publish["diagnostics"].as_array().unwrap().is_empty(),
"the unsaved model change must invalidate references in the open rules file"
);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn stale_and_incremental_changes_do_not_replace_the_current_document() {
let (server, client) = Connection::memory();
let mut documents = BTreeMap::new();
let uri = "file:///test.tes";
handle_notification(
&server,
ServerNotification::new(
DidOpenTextDocument::METHOD.to_owned(),
json!({
"textDocument": {
"uri": uri,
"languageId": "tess",
"version": 2,
"text": "module m\n"
}
}),
),
&mut documents,
)
.unwrap();
let _ = client.receiver.recv().unwrap();
handle_notification(
&server,
ServerNotification::new(
DidChangeTextDocument::METHOD.to_owned(),
json!({
"textDocument": { "uri": uri, "version": 1 },
"contentChanges": [{ "text": "broken" }]
}),
),
&mut documents,
)
.unwrap();
assert!(client.receiver.try_recv().is_err());
assert_eq!(documents[uri].text, "module m\n");
let result = handle_notification(
&server,
ServerNotification::new(
DidChangeTextDocument::METHOD.to_owned(),
json!({
"textDocument": { "uri": uri, "version": 3 },
"contentChanges": [{
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 0 }
},
"text": "broken"
}]
}),
),
&mut documents,
);
assert!(result.is_err());
assert_eq!(documents[uri].text, "module m\n");
}
#[test]
fn formatting_invalid_syntax_returns_no_edits() {
let (server, client) = Connection::memory();
let uri = Uri::from_str("file:///test.tes").unwrap();
let mut documents = BTreeMap::new();
documents.insert(
uri.as_str().to_owned(),
OpenDocument {
text: "module m\nenum E { A B }\n".to_owned(),
version: 1,
},
);
handle_request(
&server,
ServerRequest::new(
7.into(),
Formatting::METHOD.to_owned(),
json!({
"textDocument": { "uri": uri },
"options": { "tabSize": 2, "insertSpaces": true }
}),
),
&documents,
)
.unwrap();
let Message::Response(response) = client.receiver.recv().unwrap() else {
panic!("expected formatting response")
};
let ResponseKind::Ok { result } = response.response_kind else {
panic!("expected a successful null response")
};
assert!(result.is_null());
}
#[test]
fn formatting_edit_preserves_standalone_and_trailing_comments() {
let (server, client) = Connection::memory();
let uri = Uri::from_str("file:///comments.tes").unwrap();
let mut documents = BTreeMap::new();
documents.insert(
uri.as_str().to_owned(),
OpenDocument {
text: "# docs\nmodule m // header\nenum E { A, // first\nB }\n".to_owned(),
version: 1,
},
);
handle_request(
&server,
ServerRequest::new(
8.into(),
Formatting::METHOD.to_owned(),
json!({
"textDocument": { "uri": uri },
"options": { "tabSize": 4, "insertSpaces": false }
}),
),
&documents,
)
.unwrap();
let Message::Response(response) = client.receiver.recv().unwrap() else {
panic!("expected formatting response")
};
let ResponseKind::Ok { result } = response.response_kind else {
panic!("expected a successful formatting response")
};
let edits = result.as_array().unwrap();
assert_eq!(edits.len(), 1);
assert_eq!(
edits[0]["newText"].as_str().unwrap(),
"# docs\nmodule m // header\n\nenum E {\n A, // first\n B,\n}\n"
);
}
}