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, References, 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, ReferenceParams, 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},
canonical_identifier, 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)),
references_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 == References::METHOD {
return handle_references_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_references_request(
connection: &Connection,
request: ServerRequest,
documents: &BTreeMap<String, OpenDocument>,
) -> LspResult<()> {
let parameters: ReferenceParams = match serde_json::from_value(request.params.clone()) {
Ok(parameters) => parameters,
Err(error) => {
return send_error(
connection,
request,
ErrorCode::InvalidParams,
format!("invalid references 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(cursor) = LineIndex::new(&document.text).offset(position) else {
return send_error(
connection,
request,
ErrorCode::InvalidParams,
format!(
"references position {}:{} is outside document `{}`",
position.line,
position.character,
uri.as_str()
),
);
};
let references = navigation::navigation_index(uri, document, documents)
.map(|index| index.references(uri, cursor, parameters.context.include_declaration))
.unwrap_or_default();
connection.sender.send(Message::Response(Response::new_ok(
request.id,
Some(references),
)))?;
Ok(())
}
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,
},
ProvisionalFragment {
kind: ProvisionalFragmentKind,
prefix: String,
replace: Span,
fragment_start: usize,
fragment_id: String,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ProvisionalFragmentKind {
Clause,
Reference,
RuleParameterType,
FunctionParameterType,
FunctionReturnType,
}
#[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 {
fragments: 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::Fragment(fragment) => {
let name = fragment.id.value.clone();
self.fragments
.entry(name.clone())
.or_insert_with(|| NamedCompletion {
name,
detail: fragment_detail(fragment),
});
for derive in &fragment.derives {
self.add_derive(derive, Some(&fragment.id.value));
}
for rule in &fragment.rules {
self.add_rule(rule, Some(&fragment.id.value));
}
}
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!("record · {} 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) => {
self.add_derive(derive, None);
}
Declaration::Decision(decision) => {
let name = decision.name.value.clone();
self.decisions
.entry(name.clone())
.or_insert_with(|| DecisionCompletion {
name,
detail: callable_detail(
"dec",
&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) => {
self.add_rule(rule, None);
}
Declaration::State(_)
| Declaration::Action(_)
| Declaration::Transition(_)
| Declaration::Case(_)
| Declaration::Invariant(_)
| Declaration::Trace(_) => {}
}
}
}
fn add_derive(&mut self, derive: &tess::ast::DeriveDecl, fragment_id: Option<&str>) {
let name = fragment_id.map_or_else(
|| derive.name.value.clone(),
|fragment_id| format!("{fragment_id}::{}", derive.name.value),
);
self.derives
.entry(name.clone())
.or_insert_with(|| DeriveCompletion {
name,
detail: callable_detail(
"fn",
&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(),
});
}
fn add_rule(&mut self, rule: &tess::ast::RuleDecl, fragment_id: Option<&str>) {
let name = fragment_id.map_or_else(
|| rule.name.value.clone(),
|fragment_id| format!("{fragment_id}::{}", rule.name.value),
);
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: match &rule.effect {
tess::ast::Effect::Override { rule, .. } => {
let target = if rule.value.contains("::") {
rule.value.clone()
} else if let Some(fragment_id) = fragment_id {
format!("{fragment_id}::{}", rule.value)
} else {
rule.value.clone()
};
BTreeSet::from([target])
}
tess::ast::Effect::Decide { .. } | tess::ast::Effect::Invalid { .. } => {
BTreeSet::new()
}
},
});
}
fn derive<'a>(
&'a self,
name: &str,
declaration: Option<&LexicalDeclaration>,
) -> Option<&'a DeriveCompletion> {
declaration
.and_then(|declaration| declaration.fragment_id.as_deref())
.and_then(|fragment_id| self.derives.get(&format!("{fragment_id}::{name}")))
.or_else(|| self.derives.get(name))
}
fn visible_derives<'a>(
&'a self,
declaration: Option<&LexicalDeclaration>,
) -> Vec<(&'a str, &'a DeriveCompletion)> {
let local_prefix = declaration
.and_then(|declaration| declaration.fragment_id.as_deref())
.map(|fragment_id| format!("{fragment_id}::"));
let mut visible = BTreeMap::new();
for derive in self.derives.values() {
let is_local = local_prefix
.as_deref()
.and_then(|prefix| derive.name.strip_prefix(prefix))
.is_some_and(|suffix| !suffix.contains("::"));
if !is_local {
visible.insert(derive.name.as_str(), derive);
}
}
if let Some(prefix) = local_prefix.as_deref() {
for derive in self.derives.values() {
if let Some(local) = derive
.name
.strip_prefix(prefix)
.filter(|suffix| !suffix.contains("::"))
{
visible.insert(local, derive);
}
}
}
visible.into_iter().collect()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DeclarationKind {
Enum,
Record,
State,
Fn,
Dec,
Action,
Frag,
Rule,
Test,
Assert,
Transition,
Trace,
}
#[derive(Clone, Debug)]
struct LexicalDeclaration {
kind: DeclarationKind,
start: usize,
name: Option<String>,
fragment_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum CompletionContext {
TopLevel {
allow_use: bool,
},
ParameterType {
scalar: bool,
},
ReturnType {
allow_entity: bool,
},
DecisionCardinality,
RecordFieldType,
RecordFieldModifier {
allow_optional: bool,
allow_range: bool,
allow_domain: bool,
},
FragmentClause,
FragmentReference,
FunctionClause,
TransitionClause,
TraceClause,
RuleClause {
allow_basis: bool,
allow_arrow: bool,
allow_expression: bool,
},
RuleEffect,
RuleOverride,
RuleBasis,
DecisionOperator {
allow_not_equal: bool,
},
TestClause {
allow_binding: bool,
},
TestEntity,
TestExpect,
TestField {
entity: String,
used_fields: BTreeSet<String>,
},
TestFieldValue {
entity: String,
field: String,
},
AssertBody,
AssertDecision,
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.derive(derive, Some(declaration)))
.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::ProvisionalFragment {
kind,
prefix,
replace,
fragment_start,
fragment_id,
} => {
let declaration = LexicalDeclaration {
kind: DeclarationKind::Frag,
start: fragment_start,
name: Some(fragment_id),
fragment_id: None,
};
let candidates = match kind {
ProvisionalFragmentKind::Clause => vec![
keyword_candidate("ref", "link another fragment"),
keyword_candidate("fn", "declare a computed value for this fragment"),
keyword_candidate("rule", "declare a rule for this fragment"),
],
ProvisionalFragmentKind::Reference => fragment_reference_candidates(
&catalog,
current.as_ref(),
Some(&declaration),
&document.text,
cursor,
),
ProvisionalFragmentKind::RuleParameterType => entity_candidates(&catalog),
ProvisionalFragmentKind::FunctionParameterType
| ProvisionalFragmentKind::FunctionReturnType => {
let mut candidates = scalar_type_candidates(&catalog);
candidates.extend(entity_candidates(&catalog));
candidates
}
};
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()
}
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);
}
if let Some(site) = provisional_fragment_completion_site(text, 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;
}
if lexed.tokens.iter().any(|token| {
matches!(token.kind, TokenKind::RawText(_))
&& token.span.start <= cursor
&& token.span.end <= cursor
&& before[token.span.end..cursor]
.chars()
.all(char::is_whitespace)
}) {
return None;
}
let tokens: Vec<_> = lexed
.tokens
.iter()
.filter(|token| !layout_or_trivia(&token.kind))
.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 && !layout_or_trivia(&token.kind))
.cloned()
.collect();
let reference_keyword = line_tokens.iter().enumerate().find(|(index, token)| {
matches!(
token.kind,
TokenKind::Keyword(Keyword::Basis | Keyword::Ref)
) && *index == 0
|| matches!(token.kind, TokenKind::Keyword(Keyword::Override))
&& *index > 0
&& line_tokens[..*index]
.iter()
.any(|token| matches!(token.kind, TokenKind::FatArrow))
});
if let Some((keyword_index, keyword)) = reference_keyword {
let after_keyword = text.get(keyword.span.end..cursor)?;
let leading = after_keyword.len() - after_keyword.trim_start_matches([' ', '\t']).len();
if leading > 0 {
let replace_start = keyword.span.end + leading;
let prefix = text.get(replace_start..cursor)?.trim().to_owned();
return Some(CompletionSite::Word {
prefix,
replace: Span::new(replace_start, cursor),
line_start,
line_tokens: line_tokens[..=keyword_index].to_vec(),
tokens: lexed.tokens,
});
}
}
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 provisional_fragment_completion_site(text: &str, cursor: usize) -> Option<CompletionSite> {
let before = text.get(..cursor)?;
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(' ').len();
if leading != 4 || line.as_bytes().get(..leading)?.contains(&b'\t') {
return None;
}
let declaration = active_lexical_declaration(text, cursor)?;
if declaration.kind != DeclarationKind::Frag {
return None;
}
let fragment_id = declaration.name.clone()?;
let header_end = text
.get(declaration.start..line_start)?
.find(['\n', '\r'])?
+ declaration.start
+ 1;
let body_before_line = text.get(header_end..line_start)?;
let mut raw_content_seen = false;
let mut separator_seen = false;
for body_line in body_before_line.lines() {
if body_line.trim().is_empty() {
if raw_content_seen {
separator_seen = true;
}
} else if !separator_seen {
raw_content_seen = true;
}
}
if !raw_content_seen || !separator_seen {
return None;
}
let content = line.get(leading..)?;
let content_start = line_start + leading;
if content.is_empty()
|| "ref".starts_with(content)
|| "fn".starts_with(content)
|| "rule".starts_with(content)
{
return Some(CompletionSite::ProvisionalFragment {
kind: ProvisionalFragmentKind::Clause,
prefix: content.to_owned(),
replace: Span::new(content_start, cursor),
fragment_start: declaration.start,
fragment_id,
});
}
if let Some(target) = content.strip_prefix("ref ") {
let leading = target.len() - target.trim_start_matches([' ', '\t']).len();
let prefix = target.get(leading..)?.to_owned();
return Some(CompletionSite::ProvisionalFragment {
kind: ProvisionalFragmentKind::Reference,
prefix,
replace: Span::new(content_start + "ref ".len() + leading, cursor),
fragment_start: declaration.start,
fragment_id,
});
}
let (kind, callable) = if let Some(rule) = content.strip_prefix("rule ") {
(ProvisionalFragmentKind::RuleParameterType, rule)
} else {
(
ProvisionalFragmentKind::FunctionParameterType,
content.strip_prefix("fn ")?,
)
};
let open = callable.find('(')?;
let parameters = callable.get(open + 1..)?;
if let Some(close) = parameters.find(')') {
if kind != ProvisionalFragmentKind::FunctionParameterType {
return None;
}
let suffix = parameters.get(close + 1..)?;
let arrow = suffix.find("->")?;
if !suffix[..arrow].trim().is_empty() {
return None;
}
let result = suffix.get(arrow + "->".len()..)?;
let leading = result.len() - result.trim_start_matches([' ', '\t']).len();
let prefix = result.get(leading..)?;
if prefix.contains([' ', '\t']) || (prefix.ends_with(':') && !prefix.ends_with("::")) {
return None;
}
return Some(CompletionSite::ProvisionalFragment {
kind: ProvisionalFragmentKind::FunctionReturnType,
prefix: prefix.to_owned(),
replace: Span::new(cursor - prefix.len(), cursor),
fragment_start: declaration.start,
fragment_id,
});
}
let parameter = parameters
.rsplit_once(',')
.map_or(parameters, |(_, tail)| tail);
let parameter_leading = parameter.len() - parameter.trim_start_matches([' ', '\t']).len();
let parameter = parameter.get(parameter_leading..)?;
let parameter_start = cursor - parameter.len();
let (prefix, replace_start) = if parameter.is_empty() {
(String::new(), cursor)
} else if parameter.ends_with([' ', '\t']) {
let name = parameter.trim();
canonical_identifier(name)?;
(String::new(), cursor)
} else if let Some(split) = parameter.rfind([' ', '\t']) {
let name = parameter[..split].trim();
canonical_identifier(name)?;
let type_start = split + parameter[split..].len()
- parameter[split..].trim_start_matches([' ', '\t']).len();
(
parameter[type_start..].to_owned(),
parameter_start + type_start,
)
} else {
(parameter.to_owned(), parameter_start)
};
Some(CompletionSite::ProvisionalFragment {
kind,
prefix,
replace: Span::new(replace_start, cursor),
fragment_start: declaration.start,
fragment_id,
})
}
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("use")?;
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 + "use".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,
"use 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 use 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_end = open?.checked_sub(1)?;
let mut callee_start = callee_end;
while callee_start >= 2
&& matches!(tokens[callee_start - 1].kind, TokenKind::DoubleColon)
&& identifier(tokens[callee_start - 2]).is_some()
{
callee_start -= 2;
}
MemberReceiver::DeriveCall(qualified_identifier_tokens(
&tokens[callee_start..=callee_end],
)?)
} 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> {
match &token.kind {
TokenKind::Identifier(value) => Some(value),
TokenKind::Keyword(keyword) if keyword.is_contextual() => Some(keyword.as_str()),
_ => None,
}
}
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 layout_or_trivia(kind: &TokenKind) -> bool {
matches!(
kind,
TokenKind::Newline
| TokenKind::Eof
| TokenKind::Indent
| TokenKind::Dedent
| TokenKind::RawText(_)
)
}
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;
}
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 CompletionContext::TopLevel {
allow_use: declaration.is_none_or(|value| value.kind != DeclarationKind::Rule),
};
}
return CompletionContext::None;
}
let Some(declaration) = declaration else {
return CompletionContext::None;
};
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 has_open_body_parenthesis(declaration, &declaration_tokens) {
return CompletionContext::Expression;
}
match declaration.kind {
DeclarationKind::Record | DeclarationKind::State => {
if line_tokens
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::Colon))
{
CompletionContext::RecordFieldType
} else {
entity_field_modifier_context(line_tokens)
}
}
DeclarationKind::Fn => {
function_completion_context(text, declaration, line_tokens, line_start)
}
DeclarationKind::Frag => {
if line_tokens
.first()
.is_some_and(|token| matches!(token.kind, TokenKind::Keyword(Keyword::Ref)))
{
CompletionContext::FragmentReference
} else {
CompletionContext::FragmentClause
}
}
DeclarationKind::Rule => {
rule_completion_context(line_tokens, tokens, declaration.start, line_start)
}
DeclarationKind::Test => {
if let Some(binding) = open_test_binding(tokens, declaration.start, cursor) {
if line_tokens.is_empty() {
CompletionContext::TestField {
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::TestFieldValue {
entity: binding.entity,
field: field.to_owned(),
}
})
} else {
CompletionContext::None
}
} else {
test_completion_context(line_tokens, tokens, declaration.start, line_start)
}
}
DeclarationKind::Assert => assert_completion_context(line_tokens),
DeclarationKind::Transition => {
if line_tokens.first().is_some_and(|token| {
matches!(token.kind, TokenKind::Keyword(Keyword::When | Keyword::Set))
}) {
CompletionContext::Expression
} else {
CompletionContext::TransitionClause
}
}
DeclarationKind::Trace => {
if line_tokens.is_empty() {
CompletionContext::TraceClause
} else {
CompletionContext::Expression
}
}
DeclarationKind::Enum | DeclarationKind::Dec | DeclarationKind::Action => {
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 && !layout_or_trivia(&token.kind)
})
.cloned()
.collect()
}
fn continued_header_completion_context(
declaration: &LexicalDeclaration,
tokens: &[Token],
) -> Option<CompletionContext> {
if !matches!(
declaration.kind,
DeclarationKind::Rule
| DeclarationKind::Dec
| DeclarationKind::Fn
| DeclarationKind::Action
| DeclarationKind::Assert
| DeclarationKind::Transition
| DeclarationKind::Trace
) {
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::Fn,
}
} else {
CompletionContext::None
})
}
fn has_open_body_parenthesis(declaration: &LexicalDeclaration, tokens: &[Token]) -> bool {
let body_start = if matches!(
declaration.kind,
DeclarationKind::Rule
| DeclarationKind::Dec
| DeclarationKind::Fn
| DeclarationKind::Action
| DeclarationKind::Assert
| DeclarationKind::Transition
| DeclarationKind::Trace
) {
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::Dec
| DeclarationKind::Fn
| DeclarationKind::Action
| DeclarationKind::Assert
| DeclarationKind::Transition
| DeclarationKind::Trace
) && parameter_type_position(tokens)
{
return Some(CompletionContext::ParameterType {
scalar: declaration == DeclarationKind::Fn,
});
}
if matches!(declaration, DeclarationKind::Dec | DeclarationKind::Fn)
&& tokens
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::Arrow))
{
return Some(CompletionContext::ReturnType {
allow_entity: declaration == DeclarationKind::Fn,
});
}
if declaration == DeclarationKind::Dec {
let arrow = tokens
.iter()
.rposition(|token| matches!(token.kind, TokenKind::Arrow))?;
if tokens.len() == arrow + 2 {
return Some(CompletionContext::DecisionCardinality);
}
return 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::Question)
);
let modifiers = if optional_prefix {
&suffix[1..]
} else {
suffix
};
if modifiers.is_empty() {
return CompletionContext::RecordFieldModifier {
allow_optional: !optional_prefix,
allow_range: numeric,
allow_domain: domain,
};
}
if numeric && complete_range_modifier(modifiers) {
return CompletionContext::RecordFieldModifier {
allow_optional: !optional_prefix,
allow_range: false,
allow_domain: false,
};
}
CompletionContext::None
}
fn complete_range_modifier(tokens: &[Token]) -> bool {
let mut index = 0;
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;
}
let tail = tokens[open + 1..]
.rsplit(|token| matches!(token.kind, TokenKind::Comma))
.next()
.unwrap_or_default();
tail.is_empty()
|| (tail.len() == 1 && identifier(&tail[0]).is_some())
|| tail
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::DoubleColon))
}
fn rule_completion_context(
line_tokens: &[Token],
tokens: &[Token],
declaration_start: usize,
line_start: usize,
) -> CompletionContext {
let Some(first) = line_tokens.first() else {
let arrow_seen = tokens
.iter()
.filter(|token| token.span.start >= declaration_start && token.span.start < line_start)
.any(|token| matches!(token.kind, TokenKind::FatArrow));
return CompletionContext::RuleClause {
allow_basis: !arrow_seen,
allow_arrow: !arrow_seen,
allow_expression: !arrow_seen,
};
};
if matches!(first.kind, TokenKind::Keyword(Keyword::Basis)) {
return match line_tokens.get(1).map(|token| &token.kind) {
None => CompletionContext::RuleBasis,
Some(_) => CompletionContext::None,
};
}
let Some(arrow) = line_tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::FatArrow))
else {
return CompletionContext::RuleClause {
allow_basis: false,
allow_arrow: !expression_expects_operand(line_tokens),
allow_expression: true,
};
};
let effect = &line_tokens[arrow + 1..];
if effect.is_empty() {
return CompletionContext::RuleEffect;
}
if matches!(effect[0].kind, TokenKind::Keyword(Keyword::Override)) {
return if effect.len() == 1 {
CompletionContext::RuleOverride
} else {
CompletionContext::None
};
}
if completed_call_syntax(effect, 0) {
return CompletionContext::DecisionOperator {
allow_not_equal: false,
};
}
if effect
.iter()
.any(|token| matches!(token.kind, TokenKind::Equal))
{
CompletionContext::Expression
} else {
CompletionContext::RuleEffect
}
}
fn function_completion_context(
text: &str,
declaration: &LexicalDeclaration,
line_tokens: &[Token],
line_start: usize,
) -> CompletionContext {
if line_tokens
.first()
.is_some_and(|token| matches!(token.kind, TokenKind::Keyword(Keyword::Basis)))
{
return if line_tokens.len() == 1 {
CompletionContext::RuleBasis
} else {
CompletionContext::None
};
}
if line_tokens.is_empty() {
return if derive_expression_seen(text, declaration.start, line_start) {
CompletionContext::None
} else {
CompletionContext::FunctionClause
};
}
CompletionContext::Expression
}
fn test_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::TestClause {
allow_binding: !expectation_seen,
};
};
match first.kind {
TokenKind::Keyword(Keyword::Let)
if line_tokens
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::Equal)) =>
{
CompletionContext::TestEntity
}
TokenKind::Keyword(Keyword::Expect) if line_tokens.len() == 1 => {
CompletionContext::TestExpect
}
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 assert_completion_context(line_tokens: &[Token]) -> CompletionContext {
if line_tokens.is_empty() {
return CompletionContext::AssertBody;
}
if let Some(arrow) = line_tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::FatArrow))
{
let consequence = &line_tokens[arrow + 1..];
if consequence.is_empty() {
return CompletionContext::AssertDecision;
}
if completed_call_syntax(consequence, 0) {
return CompletionContext::DecisionOperator {
allow_not_equal: true,
};
}
if consequence
.iter()
.any(|token| matches!(token.kind, TokenKind::Equal | TokenKind::NotEqual))
{
return CompletionContext::Expression;
}
return CompletionContext::AssertDecision;
}
if completed_call_syntax(line_tokens, 0) {
return CompletionContext::DecisionCardinality;
}
CompletionContext::Expression
}
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 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::TopLevel { allow_use } => [
allow_use.then(|| keyword_candidate("use", "use another Tess file")),
Some(keyword_candidate("mod", "name this Tess module (optional)")),
Some(keyword_candidate("enum", "declare a finite set of values")),
Some(keyword_candidate(
"record",
"declare structured input facts",
)),
Some(keyword_candidate("state", "declare mutable workflow state")),
Some(keyword_candidate("fn", "declare a computed value")),
Some(keyword_candidate("dec", "declare a decision question")),
Some(keyword_candidate("action", "declare a workflow action")),
Some(keyword_candidate(
"transition",
"declare a state transition",
)),
Some(keyword_candidate("test", "declare a concrete example")),
Some(keyword_candidate("assert", "declare a quantified property")),
Some(keyword_candidate(
"trace",
"declare a workflow trace 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::RecordFieldType => scalar_type_candidates(catalog),
CompletionContext::RecordFieldModifier {
allow_optional,
allow_range,
allow_domain,
} => [
allow_optional.then(|| {
Candidate::new(
"?",
CompletionItemKind::OPERATOR,
"canonical optional field marker",
)
}),
allow_range.then(|| {
Candidate::new("0..", CompletionItemKind::OPERATOR, "direct numeric range")
}),
allow_domain
.then(|| Candidate::new("{", CompletionItemKind::OPERATOR, "direct finite domain")),
]
.into_iter()
.flatten()
.collect(),
CompletionContext::DecisionCardinality => vec![
Candidate::new("?", CompletionItemKind::OPERATOR, "zero or one result"),
Candidate::new("*", CompletionItemKind::OPERATOR, "many results"),
],
CompletionContext::FragmentClause => vec![
keyword_candidate("ref", "link another fragment"),
keyword_candidate("fn", "declare a computed value for this fragment"),
keyword_candidate("rule", "declare a rule for this fragment"),
],
CompletionContext::FragmentReference => {
fragment_reference_candidates(catalog, current, declaration, text, cursor)
}
CompletionContext::FunctionClause => {
let mut candidates = vec![keyword_candidate("basis", "cite another fragment")];
candidates.extend(expression_candidates(
catalog,
declaration,
text,
cursor,
line_tokens,
all_tokens,
));
candidates
}
CompletionContext::TransitionClause => vec![
keyword_candidate("when", "transition condition"),
keyword_candidate("set", "update a state field"),
],
CompletionContext::TraceClause => vec![
keyword_candidate("initially", "initial state condition"),
keyword_candidate("always", "state condition"),
keyword_candidate("terminates when", "termination condition"),
keyword_candidate("no dead ends", "forbid dead ends"),
keyword_candidate("within", "bound the trace length"),
],
CompletionContext::RuleClause {
allow_basis,
allow_arrow,
allow_expression,
} => {
let mut candidates = Vec::new();
if allow_basis {
candidates.push(keyword_candidate("basis", "cite another fragment"));
}
if allow_arrow
&& rule_condition_can_end(catalog, declaration, text, cursor, line_tokens)
{
candidates.push(Candidate::new(
"=>",
CompletionItemKind::OPERATOR,
"unconditional rule effect or finish the condition",
));
}
if allow_expression {
candidates.extend(expression_candidates(
catalog,
declaration,
text,
cursor,
line_tokens,
all_tokens,
));
}
candidates
}
CompletionContext::RuleEffect => {
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::RuleBasis => {
let mut used = declaration.map_or_else(BTreeSet::new, |declaration| {
lexical_used_rule_basis(text, declaration, cursor)
});
if let Some(fragment_id) = declaration.and_then(|declaration| {
declaration
.fragment_id
.clone()
.or_else(|| lexical_enclosing_fragment(text, declaration))
}) {
used.insert(fragment_id);
}
if let Some(rule) = current.and_then(|program| active_rule(program, cursor)) {
used.extend(rule.basis.iter().map(|basis| basis.id.value.clone()));
}
catalog
.fragments
.values()
.filter(|fragment| !used.contains(&fragment.name))
.map(|fragment| {
Candidate::new(
&fragment.name,
CompletionItemKind::REFERENCE,
&fragment.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::TestClause { allow_binding } => {
let mut candidates = if allow_binding {
entity_candidates(catalog)
} else {
Vec::new()
};
if allow_binding {
candidates.push(keyword_candidate(
"let",
"bind an aliased or repeated record input",
));
}
candidates.push(keyword_candidate("expect", "assert a decision result"));
candidates
}
CompletionContext::TestEntity => entity_candidates(catalog),
CompletionContext::TestField {
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::TestFieldValue { 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::TestExpect | CompletionContext::AssertDecision => {
scope_compatible_decisions(catalog, declaration, text, cursor)
}
CompletionContext::AssertBody => {
let mut candidates = scope_compatible_decisions(catalog, declaration, text, cursor);
candidates.extend(expression_candidates(
catalog,
declaration,
text,
cursor,
line_tokens,
all_tokens,
));
candidates
}
CompletionContext::Expression => {
expression_candidates(catalog, declaration, text, cursor, line_tokens, all_tokens)
}
CompletionContext::None => Vec::new(),
}
}
fn fragment_reference_candidates(
catalog: &CompletionCatalog,
current: Option<&Program>,
declaration: Option<&LexicalDeclaration>,
text: &str,
cursor: usize,
) -> Vec<Candidate> {
let mut used = declaration.map_or_else(BTreeSet::new, |declaration| {
lexical_used_fragment_refs(text, declaration, cursor)
});
if let Some(id) = declaration.and_then(|declaration| declaration.name.as_ref()) {
used.insert(id.clone());
}
if let Some(Declaration::Fragment(fragment)) =
current.and_then(|program| active_declaration(program, cursor))
{
used.insert(fragment.id.value.clone());
used.extend(
fragment
.refs
.iter()
.map(|reference| reference.id.value.clone()),
);
}
catalog
.fragments
.values()
.filter(|fragment| !used.contains(&fragment.name))
.map(|fragment| {
Candidate::new(
&fragment.name,
CompletionItemKind::REFERENCE,
&fragment.detail,
)
})
.collect()
}
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, 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
.visible_derives(declaration)
.into_iter()
.map(|(label, derive)| {
Candidate::new(label, 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 rule_condition_can_end(
catalog: &CompletionCatalog,
declaration: Option<&LexicalDeclaration>,
text: &str,
cursor: usize,
tokens: &[Token],
) -> bool {
if tokens.is_empty() || expression_expects_operand(tokens) {
return tokens.is_empty();
}
if tokens.iter().any(|token| {
matches!(
token.kind,
TokenKind::Equal
| TokenKind::NotEqual
| TokenKind::Greater
| TokenKind::GreaterEqual
| TokenKind::Less
| TokenKind::LessEqual
| TokenKind::Keyword(Keyword::And | Keyword::Or)
)
}) {
return true;
}
operand_type(catalog, declaration, text, cursor, tokens)
.is_some_and(|(ty, _)| matches!(ty, TypeRef::Bool | TypeRef::Unknown))
}
fn decision_rhs_candidates(
catalog: &CompletionCatalog,
tokens: &[Token],
) -> Option<Vec<Candidate>> {
let start = if matches!(
tokens.first().map(|token| &token.kind),
Some(TokenKind::Keyword(Keyword::Expect))
) {
1
} else {
tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::FatArrow))?
+ 1
};
let equal = tokens[start..]
.iter()
.position(|token| matches!(token.kind, TokenKind::Equal | TokenKind::NotEqual))?
+ start;
let name = tokens.get(start).and_then(identifier)?;
if start >= equal
|| !tokens[start + 1..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,
declaration: Option<&LexicalDeclaration>,
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
.derive(&name, declaration)
.map(|derive| &derive.parameter_types)
})
.and_then(|parameters| parameters.get(argument_index))
{
return Some(ArgumentExpectation::Exact(expected));
}
match (name.as_str(), 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<(String, 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 = qualified_identifier_ending_at(tokens, open.checked_sub(1)?)?;
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::Fn
&& 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.derive(&name, declaration))
.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.derive(&name, declaration))
}
_ => 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<String> {
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 qualified_identifier_ending_at(tokens, index.checked_sub(1)?);
}
}
_ => {}
}
}
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::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
| TokenKind::FatArrow
)
}
fn override_candidates(
catalog: &CompletionCatalog,
current: Option<&Program>,
declaration: Option<&LexicalDeclaration>,
text: &str,
cursor: usize,
) -> Vec<Candidate> {
let current_name = declaration.and_then(|declaration| {
let name = declaration.name.as_deref()?;
Some(declaration.fragment_id.as_ref().map_or_else(
|| name.to_owned(),
|fragment_id| format!("{fragment_id}::{name}"),
))
});
let current_types = declaration
.and_then(|declaration| lexical_parameter_types(text, declaration, cursor))
.or_else(|| {
current.and_then(|program| {
let rule = active_rule(program, cursor)?;
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.as_deref())
.filter(|rule| !used.contains(&rule.name))
.filter(|rule| {
current_types
.as_ref()
.is_none_or(|types| rule.parameter_types == *types)
})
.filter(|rule| {
current_name.as_deref().is_none_or(|current_name| {
!rule_reaches(catalog, &rule.name, current_name, &mut BTreeSet::new())
})
})
.map(|rule| {
let label = declaration
.and_then(|declaration| declaration.fragment_id.as_deref())
.and_then(|fragment_id| rule.name.strip_prefix(&format!("{fragment_id}::")))
.filter(|local| !local.contains("::"))
.unwrap_or(&rule.name);
Candidate::new(label, 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::Dec
| DeclarationKind::Fn
| DeclarationKind::Action
| DeclarationKind::Assert
| DeclarationKind::Transition
| DeclarationKind::Trace
) {
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| !layout_or_trivia(&token.kind))
.collect();
parameter_pairs_from_tokens(&tokens)
.map(|parameters| parameters.into_iter().map(|(_, ty)| ty).collect::<Vec<_>>())
}
fn qualified_identifier_tokens(tokens: &[&Token]) -> Option<String> {
let (first, rest) = tokens.split_first()?;
let mut value = identifier(first)?.to_owned();
let mut chunks = rest.chunks_exact(2);
for chunk in &mut chunks {
if !matches!(chunk[0].kind, TokenKind::DoubleColon) {
return None;
}
value.push_str("::");
value.push_str(identifier(chunk[1])?);
}
chunks.remainder().is_empty().then_some(value)
}
fn qualified_identifier_ending_at(tokens: &[Token], end: usize) -> Option<String> {
identifier(tokens.get(end)?)?;
let mut start = end;
while start >= 2
&& matches!(tokens[start - 1].kind, TokenKind::DoubleColon)
&& identifier(&tokens[start - 2]).is_some()
{
start -= 2;
}
let name = tokens[start..=end].iter().collect::<Vec<_>>();
qualified_identifier_tokens(&name)
}
fn parameter_pairs_from_tokens(tokens: &[&Token]) -> Option<Vec<(String, String)>> {
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 parameters = tokens[open + 1..end]
.split(|token| matches!(token.kind, TokenKind::Comma))
.filter(|parameter| !parameter.is_empty())
.collect::<Vec<_>>();
if let [parameter] = parameters.as_slice() {
if let Some(ty) = qualified_identifier_tokens(parameter) {
let name = ty.rsplit("::").next()?.to_owned();
return Some(vec![(name, ty)]);
}
}
parameters
.into_iter()
.map(|parameter| {
let (name, ty) = parameter.split_first()?;
let name = identifier(name)?.to_owned();
Some((name, qualified_identifier_tokens(ty)?))
})
.collect()
}
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| !layout_or_trivia(&token.kind))
.collect()
}
fn lexical_used_rule_basis(
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) -> BTreeSet<String> {
if !matches!(
declaration.kind,
DeclarationKind::Rule | DeclarationKind::Fn
) {
return BTreeSet::new();
}
text.get(declaration.start..cursor)
.into_iter()
.flat_map(str::lines)
.filter_map(|line| line.trim().strip_prefix("basis "))
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_owned)
.collect()
}
fn lexical_used_fragment_refs(
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) -> BTreeSet<String> {
if declaration.kind != DeclarationKind::Frag {
return BTreeSet::new();
}
text.get(declaration.start..cursor)
.into_iter()
.flat_map(str::lines)
.filter_map(|line| line.trim().strip_prefix("ref "))
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_owned)
.collect()
}
fn lexical_enclosing_fragment(text: &str, declaration: &LexicalDeclaration) -> Option<String> {
let before = text.get(..declaration.start)?;
let declaration_line = before
.rfind(['\n', '\r'])
.map_or(before, |newline| &before[newline + 1..]);
let declaration_indent = declaration_line
.chars()
.map(indentation_column)
.sum::<usize>();
before.lines().rev().find_map(|line| {
let leading = line
.chars()
.take_while(|character| matches!(character, ' ' | '\t' | '\u{000C}'))
.map(indentation_column)
.sum::<usize>();
if leading >= declaration_indent {
return None;
}
let line = line.trim();
let delimiter = line.match_indices(':').find_map(|(offset, _)| {
let trailing = line[offset + 1..].trim_start();
(trailing.is_empty() || trailing.starts_with('#')).then_some(offset)
})?;
let header = line[..delimiter].trim_end();
let id = header.split_once('@').map_or(header, |(id, _)| id).trim();
is_qualified_identifier_text(id).then(|| id.to_owned())
})
}
fn is_qualified_identifier_text(value: &str) -> bool {
!value.is_empty()
&& value
.split("::")
.all(|segment| canonical_identifier(segment).is_some())
}
fn lexical_used_rule_overrides(
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) -> BTreeSet<String> {
if declaration.kind != DeclarationKind::Rule {
return BTreeSet::new();
}
text.get(declaration.start..cursor)
.into_iter()
.flat_map(str::lines)
.filter_map(|line| {
line.split_once("override ")
.map(|(_, target)| target.trim())
})
.filter(|target| is_qualified_identifier_text(target))
.map(|target| {
if target.contains("::") {
target.to_owned()
} else if let Some(fragment_id) = &declaration.fragment_id {
format!("{fragment_id}::{target}")
} else {
target.to_owned()
}
})
.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 cursor_line_start = before.rfind(['\n', '\r']).map_or(0, |newline| newline + 1);
let cursor_indentation = before[cursor_line_start..]
.chars()
.take_while(|character| matches!(character, ' ' | '\t' | '\u{000C}'))
.map(indentation_column)
.sum::<usize>();
let mut active = None;
for (index, token) in tokens.iter().enumerate() {
let (kind, fragment_name) = if let Some(name) = fragment_header_id(tokens, index) {
(DeclarationKind::Frag, Some(name))
} else {
let Some(kind) = declaration_keyword(token) else {
continue;
};
(kind, None)
};
let line_start = before[..token.span.start]
.rfind(['\n', '\r'])
.map_or(0, |newline| newline + 1);
let indentation = &before[line_start..token.span.start];
if !horizontal_whitespace(indentation) {
continue;
}
let declaration_indentation = indentation.chars().map(indentation_column).sum::<usize>();
if kind == DeclarationKind::Frag && declaration_indentation != 0 {
continue;
}
let same_line = line_start == cursor_line_start;
if !same_line && cursor_indentation <= declaration_indentation {
continue;
}
let name = if kind == DeclarationKind::Frag {
fragment_name
} else {
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,
fragment_id: None,
});
}
if let Some(declaration) = &mut active {
if matches!(
declaration.kind,
DeclarationKind::Rule | DeclarationKind::Fn
) {
declaration.fragment_id = lexical_enclosing_fragment(text, declaration);
}
}
active
}
fn fragment_header_id(tokens: &[Token], start: usize) -> Option<String> {
if !matches!(tokens.get(start)?.kind, TokenKind::Identifier(_)) {
return None;
}
let line = tokens[start..]
.iter()
.take_while(|token| !matches!(token.kind, TokenKind::Newline | TokenKind::Eof))
.filter(|token| !layout_or_trivia(&token.kind))
.collect::<Vec<_>>();
let (colon, body) = line.split_last()?;
if !matches!(colon.kind, TokenKind::Colon) {
return None;
}
let id = if body
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::Locator(_)))
{
&body[..body.len().saturating_sub(1)]
} else {
body
};
qualified_identifier_tokens(id)
}
const fn indentation_column(character: char) -> usize {
if character == '\t' { 4 } else { 1 }
}
fn declaration_keyword(token: &Token) -> Option<DeclarationKind> {
let TokenKind::Keyword(keyword) = token.kind else {
return None;
};
Some(match keyword {
Keyword::Enum => DeclarationKind::Enum,
Keyword::Record => DeclarationKind::Record,
Keyword::State => DeclarationKind::State,
Keyword::Fn => DeclarationKind::Fn,
Keyword::Dec => DeclarationKind::Dec,
Keyword::Action => DeclarationKind::Action,
Keyword::Rule => DeclarationKind::Rule,
Keyword::Test => DeclarationKind::Test,
Keyword::Assert => DeclarationKind::Assert,
Keyword::Transition => DeclarationKind::Transition,
Keyword::Trace => DeclarationKind::Trace,
_ => return None,
})
}
struct OpenTestBinding {
entity: String,
used_fields: BTreeSet<String>,
}
fn open_test_binding(tokens: &[Token], start: usize, cursor: usize) -> Option<OpenTestBinding> {
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 mut entity_start = open.checked_sub(1)?;
while entity_start >= 2
&& matches!(significant[entity_start - 1].kind, TokenKind::DoubleColon)
&& identifier(significant[entity_start - 2]).is_some()
{
entity_start -= 2;
}
let entity = qualified_identifier_tokens(&significant[entity_start..open])?;
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(OpenTestBinding {
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::Test
| DeclarationKind::Assert
| DeclarationKind::Transition
| DeclarationKind::Trace => true,
DeclarationKind::Fn => derive_expression_line_active(text, declaration, cursor),
DeclarationKind::Enum
| DeclarationKind::Record
| DeclarationKind::State
| DeclarationKind::Dec
| DeclarationKind::Action
| DeclarationKind::Frag => false,
}
}
fn derive_expression_line_active(
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) -> bool {
let Some(before) = text.get(..cursor) else {
return false;
};
let line_start = before.rfind(['\n', '\r']).map_or(0, |newline| newline + 1);
let current = before[line_start..].trim_start();
!current.starts_with("basis ") && !derive_expression_seen(text, declaration.start, line_start)
}
fn derive_expression_seen(text: &str, declaration_start: usize, before: usize) -> bool {
let Some(body) = text.get(declaration_start..before) else {
return false;
};
let mut lines = body.lines();
lines.next();
let mut block_comment = false;
for line in lines {
let line = line.trim();
if block_comment {
if line == "#>" {
block_comment = false;
}
continue;
}
if line == "#<" {
block_comment = true;
continue;
}
if line.is_empty() || line.starts_with('#') || line.starts_with("basis ") {
continue;
}
return true;
}
false
}
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| !layout_or_trivia(&token.kind))
.collect();
let mut values = BTreeMap::new();
match declaration.kind {
DeclarationKind::Rule
| DeclarationKind::Dec
| DeclarationKind::Fn
| DeclarationKind::Action
| DeclarationKind::Assert
| DeclarationKind::Transition
| DeclarationKind::Trace => {
if let Some(parameters) = parameter_pairs_from_tokens(&tokens) {
for (name, ty) in parameters {
values.insert(
name.clone(),
ScopedCompletion {
name,
detail: format!("variable · {ty}"),
ty,
},
);
}
}
}
DeclarationKind::Test => {
for (name, entity) in lexical_test_bindings(&tokens) {
values.insert(
name.clone(),
ScopedCompletion {
name,
ty: entity.clone(),
detail: format!("variable · {entity}"),
},
);
}
}
DeclarationKind::Enum
| DeclarationKind::Record
| DeclarationKind::State
| DeclarationKind::Frag => {}
}
values.into_values().collect()
}
fn lexical_test_bindings(tokens: &[&Token]) -> Vec<(String, String)> {
let mut bindings = Vec::new();
let mut index = 0;
while index < tokens.len() {
if matches!(tokens[index].kind, TokenKind::Keyword(Keyword::Let)) {
let Some(name) = tokens.get(index + 1).and_then(|token| identifier(token)) else {
index += 1;
continue;
};
if !tokens
.get(index + 2)
.is_some_and(|token| matches!(token.kind, TokenKind::Equal))
{
index += 1;
continue;
}
if let Some((entity, open)) = qualified_name_followed_by_brace(tokens, index + 3) {
bindings.push((name.to_owned(), entity));
index = open + 1;
continue;
}
} else if let Some((entity, open)) = qualified_name_followed_by_brace(tokens, index) {
let name = entity
.rsplit("::")
.next()
.expect("a qualified record name has a final segment")
.to_owned();
bindings.push((name, entity));
index = open + 1;
continue;
}
index += 1;
}
bindings
}
fn qualified_name_followed_by_brace(tokens: &[&Token], start: usize) -> Option<(String, usize)> {
identifier(tokens.get(start)?)?;
let mut end = start + 1;
while tokens
.get(end)
.is_some_and(|token| matches!(token.kind, TokenKind::DoubleColon))
&& tokens
.get(end + 1)
.and_then(|token| identifier(token))
.is_some()
{
end += 2;
}
if !tokens
.get(end)
.is_some_and(|token| matches!(token.kind, TokenKind::LeftBrace))
{
return None;
}
Some((qualified_identifier_tokens(&tokens[start..end])?, end))
}
fn callable_detail(kind: &str, parameters: &[Parameter], return_type: &str) -> String {
format!("{kind} · {} -> {return_type}", parameter_list(parameters))
}
fn parameter_list(parameters: &[Parameter]) -> String {
if let [parameter] = parameters {
let implicit_name = parameter
.ty
.value
.rsplit("::")
.next()
.unwrap_or(¶meter.ty.value);
if parameter.name.value == implicit_name {
return format!("({})", parameter.ty.value);
}
}
format!(
"({})",
parameters
.iter()
.map(|parameter| format!("{} {}", parameter.name.value, parameter.ty.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::Fragment(fragment) => fragment
.derives
.iter()
.filter(|derive| derive.span.start <= cursor && cursor <= derive.span.end)
.max_by_key(|derive| derive.span.start)
.and_then(|derive| parameter_entity(&derive.parameters, receiver))
.or_else(|| {
fragment
.rules
.iter()
.filter(|rule| rule.span.start <= cursor && cursor <= rule.span.end)
.max_by_key(|rule| rule.span.start)
.and_then(|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 && cursor <= declaration.span().end
})
.max_by_key(|declaration| declaration.span().start)
}
fn active_rule(program: &Program, cursor: usize) -> Option<&tess::ast::RuleDecl> {
match active_declaration(program, cursor)? {
Declaration::Rule(rule) => Some(rule),
Declaration::Fragment(fragment) => fragment
.rules
.iter()
.filter(|rule| rule.span.start <= cursor && cursor <= rule.span.end)
.max_by_key(|rule| rule.span.start),
_ => None,
}
}
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 fragment_detail(fragment: &tess::ast::FragmentDecl) -> String {
let identity = if fragment.id.value == fragment.locator.value {
fragment.id.value.clone()
} else {
format!("{} @ {}", fragment.id.value, fragment.locator.value)
};
let summary = fragment
.text
.value
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(|line| truncate_detail(line, 72));
summary.map_or_else(
|| format!("fragment · {identity}"),
|summary| format!("fragment · {identity} · {summary}"),
)
}
fn truncate_detail(text: &str, limit: usize) -> String {
let mut characters = text.chars();
let prefix = characters.by_ref().take(limit).collect::<String>();
if characters.next().is_some() {
format!("{prefix}…")
} else {
prefix
}
}
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,
" · {}..{}",
numeric_literal(&range.start),
numeric_literal(&range.end)
);
}
if let Some(domain) = &field.domain {
let _ = write!(detail, " · {{{} 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 a `use` 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 caret_marker = "<caret>";
let cursor = marked
.find(caret_marker)
.expect("completion fixture needs one marker");
assert_eq!(marked.rfind(caret_marker), Some(cursor));
let text = marked.replacen(caret_marker, "", 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 line_index_round_trips_utf16_positions() {
let text = "a😀한\r\nb";
let index = LineIndex::new(text);
for byte in [0, 1, 5, 8, 10, text.len()] {
assert_eq!(index.offset(index.position(byte)), Some(byte));
}
assert_eq!(index.offset(Position::new(0, 2)), None);
assert_eq!(index.full_range().end, Position::new(1, 1));
}
#[test]
fn top_level_completion_uses_only_the_new_surface_words() {
assert_eq!(labels(&complete_at_marker("mo<caret>")), ["mod"]);
let items = complete_at_marker("<caret>");
let labels = labels(&items);
for keyword in ["use", "mod", "record", "fn", "dec", "test", "assert"] {
assert!(labels.contains(&keyword), "missing {keyword}: {labels:?}");
}
for non_keyword in [
"module",
"import",
"entity",
"derive",
"decision",
"case",
"invariant",
"source",
"frag",
"text",
] {
assert!(
!labels.contains(&non_keyword),
"non-keyword completion leaked: {non_keyword}"
);
}
}
#[test]
fn comments_strings_and_fragment_text_suppress_completion() {
assert!(complete_at_marker("# comment <caret>").is_empty());
assert!(complete_at_marker("#<\ncomment <caret>").is_empty());
assert!(complete_at_marker("use \"unfinished<caret>").is_empty());
assert!(
complete_at_marker("법::제1조@조 1:\n 법 원문 안의 # 기호와 rule <caret>\n")
.is_empty()
);
}
#[test]
fn record_types_constraints_and_decision_suffixes_are_direct() {
let numeric_items = complete_at_marker("record 신청:\n 점수: Int <caret>\n");
let numeric = labels(&numeric_items);
assert_eq!(numeric, ["?", "0.."]);
let string_items = complete_at_marker("record 신청:\n 종류: String <caret>\n");
let string = labels(&string_items);
assert_eq!(string, ["?", "{"]);
let cardinality_items =
complete_at_marker("record 신청:\n 점수: Int\n\ndec 사유(신청) -> String <caret>\n");
let cardinality = labels(&cardinality_items);
assert_eq!(cardinality, ["?", "*"]);
}
#[test]
fn four_space_layout_selects_fragment_and_nested_rule_contexts() {
let fragment_level = complete_at_marker(
"record 신청:\n 국내: Bool\n\ndec 결과(신청) -> Bool\n\n법::제1조@조 1:\n 원문.\n\n rule 국내(신청):\n => 결과(신청) = true\n\n <caret>\n",
);
assert_eq!(labels(&fragment_level), ["ref", "fn", "rule"]);
let rule_level = complete_at_marker(
"record 신청:\n 국내: Bool\n\ndec 결과(신청) -> Bool\n\n법::제1조@조 1:\n 원문.\n\n rule 국내(신청):\n <caret>\n",
);
let labels = labels(&rule_level);
assert!(labels.contains(&"basis"));
assert!(labels.contains(&"=>"));
assert!(labels.contains(&"신청"));
}
#[test]
fn nested_rule_parameters_drive_member_completion() {
let items = complete_at_marker(
"record 신청:\n 국내: Bool\n 점수: Int 0..100\n\n법::제1조@조 1:\n 원문.\n\n rule 국내(신청):\n 신청.<caret>\n",
);
assert_eq!(labels(&items), ["국내", "점수"]);
assert!(items[1].detail.as_deref().unwrap().contains("0..100"));
}
#[test]
fn fragment_local_functions_complete_headers_bodies_and_calls() {
let declarations = concat!(
"record 신청:\n 국내: Bool\n 점수: Int\n\n",
"법::근거:\n 근거 원문.\n\n",
"법::적용:\n 적용 원문.\n\n",
);
let parameters = complete_at_marker(&format!("{declarations} fn 국내판정(신<caret>\n"));
assert_eq!(labels(¶meters), ["신청"]);
let return_types = complete_at_marker(&format!(
"{declarations} fn 국내판정(신청) -> B<caret>\n"
));
assert_eq!(labels(&return_types), ["Bool"]);
let members = complete_at_marker(&format!(
"{declarations} fn 국내판정(신청) -> Bool:\n basis 법::근거\n 신청.<caret>\n"
));
assert_eq!(labels(&members), ["국내", "점수"]);
let local_call = complete_at_marker(&format!(
"{declarations} fn 국내판정(신청) -> Bool:\n 신청.국내\n\n rule 적용(신청):\n <caret>\n"
));
let names = labels(&local_call);
assert!(names.contains(&"국내판정"));
assert!(!names.contains(&"법::적용::국내판정"));
let external_argument = complete_at_marker(&format!(
"{declarations} fn 국내판정(신청) -> Bool:\n 신청.국내\n\nrule 외부(신청):\n 법::적용::국내판정(<caret>\n"
));
assert_eq!(labels(&external_argument), ["신청"]);
let external_calls = complete_at_marker(&format!(
"{declarations} fn 국내판정(신청) -> Bool:\n 신청.국내\n\nrule 외부(신청):\n <caret>\n"
));
assert!(labels(&external_calls).contains(&"법::적용::국내판정"));
let external_member = complete_at_marker(&format!(
"{declarations} fn 그대로(신청) -> 신청:\n 신청\n\nrule 외부(신청):\n 법::적용::그대로(신청).<caret>\n"
));
assert_eq!(labels(&external_member), ["국내", "점수"]);
}
#[test]
fn fragment_local_function_basis_completion_skips_enclosing_fragment() {
let items = complete_at_marker(concat!(
"법::근거:\n 근거 원문.\n\n",
"법::추가:\n 추가 원문.\n\n",
"법::적용:\n 적용 원문.\n\n",
" fn 판정(Bool) -> Bool:\n",
" basis 법::근거\n",
" basis 법::<caret>\n",
));
assert_eq!(labels(&items), ["법::추가"]);
}
#[test]
fn basis_completion_uses_stable_ids_and_shows_native_locators() {
let items = complete_at_marker(
"정책::제1조@정책 / 제1조 [개정42]:\n 첫째 원문.\n\n정책::적용@정책 / 적용:\n 둘째 원문.\n\n rule 결론(신청):\n basis 정책::제<caret>\n",
);
assert_eq!(labels(&items), ["정책::제1조"]);
let detail = items[0].detail.as_deref().unwrap();
assert!(detail.contains("fragment · 정책::제1조 @ 정책 / 제1조 [개정42]"));
assert!(detail.contains("첫째 원문."));
}
#[test]
fn test_and_assert_bodies_offer_contextual_decisions() {
let test_items = complete_at_marker("test 기본:\n <caret>\n");
assert_eq!(labels(&test_items), ["let", "expect"]);
let assert_items = complete_at_marker(
"record 신청:\n 국내: Bool\n\ndec 결과(신청) -> Bool\n\nassert 완전(신청):\n <caret>\n",
);
let labels = labels(&assert_items);
assert!(labels.contains(&"결과"));
assert!(labels.contains(&"신청"));
}
#[test]
fn fragment_detail_is_short_and_keeps_the_locator() {
let parsed = parse(SourceFile::new(
"detail.tes",
"법::제1조@법 / 제1조:\n 첫 줄 원문입니다.\n 둘째 줄입니다.\n",
));
let Declaration::Fragment(fragment) = &parsed.program.unwrap().declarations[0] else {
panic!("expected fragment")
};
assert_eq!(
fragment_detail(fragment),
"fragment · 법::제1조 @ 법 / 제1조 · 첫 줄 원문입니다."
);
}
#[test]
fn nested_rule_recovers_its_fragment_id_after_a_header_comment() {
let text =
"법::적용@제4조 / 제1항: # 원문 위치\n 원문.\n\n rule 적용(신청):\n ";
let declaration = active_lexical_declaration(text, text.len()).unwrap();
assert_eq!(declaration.kind, DeclarationKind::Rule);
assert_eq!(declaration.fragment_id.as_deref(), Some("법::적용"));
}
#[test]
fn fragment_code_completion_requires_the_blank_separator() {
assert!(
complete_at_marker("법::적용:\n 원문.\n <caret>").is_empty(),
"a raw paragraph must not offer code before its separator"
);
assert_eq!(
labels(&complete_at_marker("법::적용:\n 원문.\n\n <caret>")),
["ref", "fn", "rule"]
);
}
#[test]
fn incomplete_fragment_items_complete_across_the_raw_boundary() {
let references = complete_at_marker(
"law::source:\n Source.\n\nlaw::consumer:\n Consumer.\n\n ref law::<caret>\n",
);
assert_eq!(labels(&references), ["law::source"]);
let types = complete_at_marker(
"record 신청:\n 국내: Bool\n\nlaw::consumer:\n Consumer.\n\n rule 적용(신<caret>\n",
);
assert_eq!(labels(&types), ["신청"]);
}
#[test]
fn qualified_override_completion_replaces_the_whole_stable_rule_id() {
let items = complete_at_marker(
"record 신청:\n 국내: Bool\n\ndec 결과(신청) -> Bool\n\n법::원문:\n 원문.\n\n rule 기본(신청):\n => 결과(신청) = true\n\n법::예외:\n 예외.\n\n rule 예외(신청):\n => override 법::원<caret>\n",
);
assert_eq!(labels(&items), ["법::원문::기본"]);
let CompletionTextEdit::Edit(edit) = items[0].text_edit.as_ref().unwrap() else {
panic!("expected a plain completion edit")
};
assert_eq!(edit.new_text, "법::원문::기본");
}
#[test]
fn completion_sites_recognize_members_references_and_comments() {
let exact_member = " 신청.";
assert_eq!(
completion_site(exact_member, exact_member.len()),
Some(CompletionSite::Member {
receiver: MemberReceiver::Name("신청".to_owned()),
prefix: String::new(),
replace: Span::new(exact_member.len(), exact_member.len()),
})
);
let prefixed_member = " 신청.결";
assert_eq!(
completion_site(prefixed_member, prefixed_member.len()),
Some(CompletionSite::Member {
receiver: MemberReceiver::Name("신청".to_owned()),
prefix: "결".to_owned(),
replace: Span::new(prefixed_member.len() - "결".len(), prefixed_member.len()),
})
);
for reference in [" basis ", " basis 규정::"] {
let Some(CompletionSite::Word {
prefix, replace, ..
}) = completion_site(reference, reference.len())
else {
panic!("expected a reference completion site for {reference:?}")
};
assert_eq!(prefix, reference.trim_start().trim_start_matches("basis "));
assert_eq!(replace.end, reference.len());
}
assert_eq!(completion_site(" # basis ", " # basis ".len()), None);
let fn_member = " 그대로(신청).";
assert_eq!(
completion_site(fn_member, fn_member.len()),
Some(CompletionSite::Member {
receiver: MemberReceiver::DeriveCall("그대로".to_owned()),
prefix: String::new(),
replace: Span::new(fn_member.len(), fn_member.len()),
})
);
}
#[test]
fn use_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-use-{}-{id}", std::process::id()));
let rules = directory.join("rules");
std::fs::create_dir_all(&rules).unwrap();
std::fs::write(rules.join("policy.tes"), "mod policy\n").unwrap();
std::fs::write(rules.join("notes.txt"), "not Tess\n").unwrap();
let main = directory.join("main.tes");
std::fs::write(&main, "mod main\n").unwrap();
let uri = Uri::from_str(&format!("file://{}", main.display())).unwrap();
for (text, expected) in [
("mod main\nuse \"ru", vec!["rules/"]),
("mod main\nuse \"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 rule_effects_and_compatible_override_targets_are_contextual() {
let declarations = concat!(
"mod m\n",
"record 학생:\n 점수: Int\n\n",
"record 교사:\n 이름: String\n\n",
"enum 등급:\n A\n F\n\n",
"dec 최종등급(학생) -> 등급\n\n",
"rule 기본규칙(s 학생):\n true => 최종등급(s) = F\n\n",
"rule 다른타입(t 교사):\n true => 최종등급(t) = F\n\n",
);
let items = complete_at_marker(&format!(
"{declarations}rule 현재규칙(s 학생):\n true => <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("dec · (학생) -> 등급"));
let items = complete_at_marker(&format!(
"{declarations}rule 현재규칙(s 학생):\n true => override 기<caret>"
));
assert_eq!(labels(&items), ["기본규칙"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::REFERENCE));
assert_eq!(items[0].detail.as_deref(), Some("rule · (s 학생)"));
}
#[test]
fn decision_names_and_arguments_follow_multi_parameter_scope_types() {
let declarations = concat!(
"mod m\n",
"record 학생:\n 점수: Int\n\n",
"record 교사:\n 이름: String\n\n",
"record 교실:\n 호수: Int\n\n",
"dec 학생판정(학생) -> Bool\n",
"dec 역순배정(t 교사, s 학생) -> Bool\n",
"dec 교실판정(교실) -> Bool\n\n",
);
let items = complete_at_marker(&format!(
"{declarations}rule 배정(s 학생, t 교사):\n true => <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 true => 역순배정(<caret>"
));
assert_eq!(labels(&items), ["t"]);
let items = complete_at_marker(&format!(
"{declarations}rule 배정(s 학생, t 교사):\n true => 역순배정(t, <caret>"
));
assert_eq!(labels(&items), ["s"]);
let items = complete_at_marker(&format!(
"{declarations}test 배정예시:\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}assert 배정완전성(s 학생, t 교사):\n <caret>"
));
let names = labels(&items);
assert!(names.contains(&"학생판정"));
assert!(names.contains(&"역순배정"));
assert!(!names.contains(&"교실판정"));
}
#[test]
fn override_targets_exclude_duplicates_and_transitive_cycles_during_recovery() {
let items = complete_at_marker(concat!(
"mod m\n",
"record 입력:\n 값: Int\n\n",
"dec 판정(입력) -> Bool\n\n",
"rule 안전(입력):\n true => 판정(입력) = true\n\n",
"rule 중복(입력):\n true => 판정(입력) = true\n\n",
"rule 순환A(입력):\n true => override 순환B\n\n",
"rule 순환B(입력):\n true => override 현재\n\n",
"rule 현재(입력):\n",
" true => 판정(입력) = true\n",
" true => override 중복\n",
" true => override <caret>",
));
assert_eq!(labels(&items), ["안전"]);
}
#[test]
fn rule_expression_starters_include_scope_functions_builtins_and_values() {
let text = concat!(
"mod m\n",
"enum 등급:\n A\n F\n\n",
"record 학생:\n 점수: Int\n\n",
"fn 통과가능(학생) -> Bool:\n 학생.점수 >= 60\n\n",
"dec 최종등급(학생) -> 등급\n\n",
"rule 현재(s 학생):\n",
" <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("<caret>", "true <caret>"));
assert_eq!(labels(&items), ["=>", "and", "or", "=", "!="]);
}
#[test]
fn test_completions_cover_clauses_records_fields_values_and_expectations() {
let declarations = concat!(
"mod m\n",
"enum 등급:\n A\n F\n\n",
"record 학생:\n 총점: Int 0..100\n 결석: Int\n 등급값: 등급\n\n",
"dec 최종등급(학생) -> 등급\n\n",
);
let items = complete_at_marker(&format!("{declarations}test 예시:\n le<caret>"));
assert_eq!(labels(&items), ["let"]);
assert_eq!(
items[0].detail.as_deref(),
Some("bind an aliased or repeated record input")
);
let items = complete_at_marker(&format!("{declarations}test 예시:\n 학<caret>"));
assert_eq!(labels(&items), ["학생"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::CLASS));
let items = complete_at_marker(&format!("{declarations}test 예시:\n let s = <caret>"));
assert_eq!(labels(&items), ["학생"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::CLASS));
let items = complete_at_marker(&format!(
"{declarations}test 예시:\n 학생 {{\n 총<caret>"
));
assert_eq!(labels(&items), ["총점"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::FIELD));
assert_eq!(items[0].detail.as_deref(), Some("학생 · Int · 0..100"));
let items = complete_at_marker(&format!(
"{declarations}test 예시:\n 학생 {{ 총점: 100, 결석: 0, 등급값: A }}\n expect <caret>"
));
assert_eq!(labels(&items), ["최종등급"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::FUNCTION));
let items = complete_at_marker(&format!(
"{declarations}test 예시:\n 학생 {{\n 등급값: A<caret>"
));
assert_eq!(labels(&items), ["A"]);
assert_eq!(items[0].kind, Some(CompletionItemKind::ENUM_MEMBER));
let items = complete_at_marker(&format!(
"{declarations}test 예시:\n 학생 {{ 총점: 100, 결석: 0, 등급값: A }}\n expect 최종등급(학생) = A\n <caret>"
));
assert_eq!(labels(&items), ["expect"]);
}
#[test]
fn test_values_are_type_directed_and_required_fields_sort_first() {
let declarations = concat!(
"mod m\n",
"enum 왼쪽:\n 공통\n 왼쪽만\n\n",
"enum 오른쪽:\n 공통\n 오른쪽만\n\n",
"record 입력:\n",
" 선택사항: 왼쪽?\n",
" 활성: Bool\n",
" 결과: 오른쪽\n\n",
);
let items = complete_at_marker(&format!(
"{declarations}test 예시:\n let x = 입력 {{\n <caret>"
));
assert_eq!(labels(&items), ["활성", "결과", "선택사항"]);
let items = complete_at_marker(&format!(
"{declarations}test 예시:\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}test 예시:\n let x = 입력 {{\n 선택사항: <caret>"
));
let names = labels(&items);
assert!(names.contains(&"공통"));
assert!(names.contains(&"왼쪽만"));
assert!(names.contains(&"unknown"));
assert!(!names.contains(&"오른쪽만"));
let temporal = concat!(
"mod m\n",
"record 일정:\n 시작일: Date\n 유예: Duration?\n\n",
"test 예시:\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_function_arguments_follow_expected_types() {
let declarations = concat!(
"mod m\n",
"enum 등급:\n 일반\n 우수\n\n",
"enum 상태:\n 일반\n 휴면\n\n",
"record 고객:\n 등급값: 등급\n 활성: Bool?\n 금액: Int\n\n",
"record 교사:\n 이름: String\n\n",
"fn 현재등급(c 고객) -> 등급:\n c.등급값\n\n",
"fn 그대로(c 고객) -> 고객:\n c\n\n",
);
let items = complete_at_marker(&format!(
"{declarations}fn 기본등급(c 고객) -> 등급:\n <caret>"
));
let names = labels(&items);
assert_eq!(names.len(), 3);
assert!(names.contains(&"basis"));
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 {condition}"
));
let names = labels(&items);
assert_eq!(names.len(), 2, "condition {condition:?}: {names:?}");
assert!(names.contains(&"일반"));
assert!(names.contains(&"우수"));
assert!(!names.contains(&"휴면"));
}
let items = complete_at_marker(&format!(
"{declarations}rule 정책(c 고객):\n c.활성 = <caret>"
));
assert_eq!(labels(&items), ["true", "false", "unknown"]);
let items = complete_at_marker(&format!(
"{declarations}rule 정책(c 고객, t 교사):\n 현재등급(<caret>"
));
assert_eq!(labels(&items), ["c"]);
let items = complete_at_marker(&format!(
"{declarations}rule 정책(c 고객):\n c.등급값 <caret>"
));
assert_eq!(labels(&items), ["=", "!="]);
let items = complete_at_marker(&format!(
"{declarations}rule 정책(c 고객):\n c.금액 <caret>"
));
assert_eq!(
labels(&items),
["=", "!=", ">", ">=", "<", "<=", "+", "-", "*", "/"]
);
let items = complete_at_marker(&format!(
"{declarations}rule 정책(c 고객):\n c.금액 >= 10 <caret>"
));
assert_eq!(labels(&items), ["=>", "and", "or"]);
let items = complete_at_marker(
"mod m\nfn 날짜(text String, count Int, rate Decimal, flag Bool) -> Date:\n date(<caret>",
);
assert_eq!(labels(&items), ["text"]);
let items = complete_at_marker(
"mod m\nfn 큰값(text String, count Int, rate Decimal, flag Bool) -> Decimal:\n max(<caret>",
);
assert_eq!(labels(&items), ["count", "rate"]);
}
#[test]
fn used_fragment_links_rule_basis_and_test_fields_are_not_reoffered() {
let declarations = concat!(
"mod m\n",
"규정::첫째:\n 첫째 원문.\n\n",
"규정::둘째:\n 둘째 원문.\n\n",
"record 입력:\n 필수A: Bool\n 선택: Bool?\n 필수B: Bool\n\n",
"dec 판정(입력) -> Bool\n\n",
);
let items = complete_at_marker(&format!(
"{declarations}규정::연결:\n 연결 원문.\n\n ref 규정::첫째\n ref <caret>"
));
assert_eq!(labels(&items), ["규정::둘째"]);
let items = complete_at_marker(&format!(
"{declarations}규정::연결:\n 연결 원문.\n\n rule r(입력):\n basis 규정::첫째\n basis <caret>"
));
assert_eq!(labels(&items), ["규정::둘째"]);
let items = complete_at_marker(&format!(
"{declarations}test c:\n let x = 입력 {{\n 필수A: true,\n <caret>"
));
assert_eq!(labels(&items), ["필수B", "선택"]);
}
#[test]
fn assert_completions_follow_direct_and_implication_states() {
let declarations = concat!(
"mod m\n",
"enum 등급:\n A\n F\n\n",
"record 학생:\n 점수: Int\n\n",
"dec 최종등급(학생) -> 등급\n\n",
);
let items = complete_at_marker(&format!("{declarations}assert 완전성(학생):\n <caret>"));
let names = labels(&items);
assert!(names.contains(&"최종등급"));
assert!(names.contains(&"학생"));
let items = complete_at_marker(&format!(
"{declarations}assert 완전성(학생):\n 학생.점수 >= 60 => <caret>"
));
assert_eq!(labels(&items), ["최종등급"]);
let items = complete_at_marker(&format!(
"{declarations}assert 완전성(학생):\n 학생.점수 >= 60 => 최종등급(학생) <caret>"
));
assert_eq!(labels(&items), ["=", "!="]);
let items = complete_at_marker(&format!(
"{declarations}assert some 존재성(학생):\n 학생.점수 >= 60 => 최종등급(학생) = <caret>"
));
assert_eq!(labels(&items), ["A", "F"]);
}
#[test]
fn type_function_and_body_scope_completions_stay_in_valid_positions() {
let declarations = concat!(
"mod m\n",
"enum 등급:\n A\n F\n\n",
"record 학생:\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}dec d(학생) -> <caret>"));
let type_labels = labels(&items);
assert!(type_labels.contains(&"Bool"));
assert!(type_labels.contains(&"등급"));
assert!(!type_labels.contains(&"학생"));
let items = complete_at_marker(&format!("{declarations}fn 그대로(학생) -> <caret>"));
assert!(labels(&items).contains(&"학생"));
let items = complete_at_marker("mod m\nrecord 학생:\n 점수: <caret>");
assert!(labels(&items).contains(&"Int"));
let items = complete_at_marker(&format!(
"{declarations}fn 점수(학생) -> Int:\n 학생.<caret>"
));
assert_eq!(labels(&items), ["점수"]);
let items = complete_at_marker(&format!(
"{declarations}fn 그대로(학생) -> 학생:\n 학생\n\nfn 점수(학생) -> Int:\n 그대로(학생).<caret>"
));
assert_eq!(labels(&items), ["점수"]);
assert!(complete_at_marker("mod m\nuse \"unfinished<caret>").is_empty());
assert!(complete_at_marker(&format!("{declarations} 학생.<caret>")).is_empty());
assert!(
complete_at_marker(&format!(
"{declarations}fn 점수(학생) -> Int:\n 학생.점수\n 학생.<caret>"
))
.is_empty()
);
}
#[test]
fn finite_domains_cardinality_and_multiline_headers_keep_parser_context() {
let declarations = concat!(
"mod m\n",
"record 학생:\n 점수: Int\n\n",
"record 교사:\n 이름: String\n\n",
"dec 판정(s 학생, t 교사) -> Bool\n\n",
);
let items = complete_at_marker(&format!("{declarations}rule 정책(\n <caret>"));
assert_eq!(labels(&items), ["교사", "학생"]);
let items =
complete_at_marker(&format!("{declarations}fn 계산(\n s 학생,\n <caret>"));
let names = labels(&items);
assert!(names.contains(&"Int"));
assert!(names.contains(&"교사"));
let items = complete_at_marker(&format!(
"{declarations}fn 그대로(학생) -> 학생:\n <caret>"
));
assert!(labels(&items).contains(&"학생"));
for body in [
"rule 정책(s 학생, t 교사):\n 판정(\n <caret>",
"test 예시:\n let s = 학생 { 점수: 100 }\n let t = 교사 { 이름: \"담임\" }\n expect 판정(\n <caret>",
"assert 완전성(s 학생, t 교사):\n 판정(\n <caret>",
] {
let items = complete_at_marker(&format!("{declarations}{body}"));
assert_eq!(labels(&items), ["s"], "body: {body}");
}
let items = complete_at_marker(&format!("{declarations}dec 선택(학생) -> Bool <caret>"));
assert_eq!(labels(&items), ["?", "*"]);
let domains = concat!(
"mod m\n",
"record 입력:\n 상태: String {\"draft\", \"approved\"}\n 비율: Decimal {0.05, 0.10}\n\n",
);
let items = complete_at_marker(&format!(
"{domains}test 예시:\n let x = 입력 {{\n 상태: <caret>"
));
assert_eq!(labels(&items), ["\"approved\"", "\"draft\""]);
let items = complete_at_marker(&format!(
"{domains}test 예시:\n let x = 입력 {{\n 비율: <caret>"
));
assert_eq!(labels(&items), ["0.05", "0.10"]);
}
#[test]
fn completion_scope_does_not_leak_across_declarations() {
let items = complete_at_marker(concat!(
"mod m\n",
"record 학생:\n 점수: Int\n\n",
"dec 결과(학생) -> Bool\n\n",
"rule 이전(s 학생):\n true => 결과(s) = true\n\n",
"record 다음:\n 값: Bool\n\n",
" s.<caret>",
));
assert!(items.is_empty());
assert!(complete_at_marker("mod m\n# s.<caret>").is_empty());
}
#[test]
fn member_completion_selects_the_receiver_in_multi_parameter_rules() {
let text = concat!(
"mod assignments\n",
"record 학생:\n 총점: Int\n\n",
"record 교사:\n 이름: String\n\n",
"dec 배정(s 학생, t 교사) -> Bool\n\n",
"rule 배정규칙(s 학생, t 교사):\n",
" s.\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!(labels(&items), ["총점"]);
}
#[test]
fn contextual_completion_filters_korean_fragment_ids_and_fields() {
let text = concat!(
"mod 자동완성\n",
"강의계획서::제4조 @강의계획서 / 제4조:\n",
" 강의계획서 제4조 원문.\n\n",
"학칙::제2조:\n",
" 학칙 제2조 원문.\n\n",
"record 학생:\n",
" 총점: Int 0..100\n",
" 결석횟수: Int? 0..15\n\n",
"dec 최종등급(학생) -> Int\n\n",
"학칙::판정:\n",
" 성적 판정 원문.\n\n",
" rule 등급(s 학생):\n",
" s.결\n",
" basis 강\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!(labels(&member_items), ["결석횟수"]);
assert_eq!(member_items[0].kind, Some(CompletionItemKind::FIELD));
assert_eq!(
member_items[0].detail.as_deref(),
Some("학생 · Int · 0..15 · optional")
);
let basis_cursor = text.rfind("basis 강").unwrap() + "basis 강".len();
let basis_items = completion_items(
&uri,
document,
LineIndex::new(text).position(basis_cursor),
&documents,
)
.unwrap();
assert_eq!(labels(&basis_items), ["강의계획서::제4조"]);
assert_eq!(basis_items[0].kind, Some(CompletionItemKind::REFERENCE));
assert!(
basis_items[0]
.detail
.as_deref()
.unwrap()
.contains("강의계획서 / 제4조")
);
}
#[test]
fn incomplete_nested_rule_completion_uses_unsaved_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/domain/request.tes");
let rules = directory.join("src/policy/eligibility.tes");
let mut model_text = std::fs::read_to_string(&model).unwrap();
model_text
.push_str("\nhandbook::section @Handbook / 4:\n Eligibility handbook section.\n");
let rules_text = std::fs::read_to_string(&rules).unwrap().replacen(
" Request.enabled => eligibility(Request) = Eligible",
" basis handbook::\n Request.",
1,
);
let rules_uri = Uri::from_str(&format!("file://{}", rules.display())).unwrap();
let model_uri = Uri::from_str(&format!("file://{}", model.display())).unwrap();
let mut documents = BTreeMap::new();
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!(labels(&field_items), ["enabled"]);
assert_eq!(field_items[0].detail.as_deref(), Some("Request · Bool"));
let basis_cursor = rules_text.find("basis handbook::").unwrap() + "basis handbook::".len();
let basis_items = completion_items(
&rules_uri,
document,
index.position(basis_cursor),
&documents,
)
.unwrap();
assert_eq!(labels(&basis_items), ["handbook::section"]);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn diagnostics_keep_severity_code_help_and_related_locations() {
let text = "mod 😀\n";
let uri = Uri::from_str("file:///test.tes").unwrap();
let diagnostic = TessDiagnostic::warning(
"W0001",
"sample warning",
Span::new("mod ".len(), "mod 😀".len()),
)
.with_label(Span::new(0, 3), "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, 4), Position::new(0, 6))
);
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_uses_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/policy/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/domain/request.tes");
let rules = directory.join("src/policy/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": "mod 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, "mod 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, "mod 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: "mod m\nenum E:\n 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_hash_comments_and_four_space_layout() {
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\nmod m # header\nenum E:\n A # first\n B\n".to_owned(),
version: 1,
},
);
handle_request(
&server,
ServerRequest::new(
8.into(),
Formatting::METHOD.to_owned(),
json!({
"textDocument": { "uri": uri },
"options": { "tabSize": 2, "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\nmod m # header\n\nenum E:\n A # first\n B\n"
);
}
}