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::compiler::render_range_constraint;
use tess::{
Diagnostic as TessDiagnostic, Severity, SourceFile, Span,
ast::{Declaration, 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(),
"\"".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,
qualified_prefix: String,
qualified_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,
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_names: Vec<String>,
parameter_types: Vec<String>,
return_type: Option<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(Clone, Debug)]
struct FixtureCompletion {
name: String,
entity: 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>,
fixtures: BTreeMap<String, FixtureCompletion>,
}
impl CompletionCatalog {
fn add_program(&mut self, program: &Program) {
for declaration in &program.declarations {
if is_private_symbol(&declaration.name().value) {
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.insert(
name.clone(),
DecisionCompletion {
name,
detail: callable_detail(
"inferred decision",
&decision.parameters,
type_name(&decision.return_type.value),
),
parameter_types: decision
.parameters
.iter()
.map(parameter_type_name)
.collect(),
return_type: decision.return_type.value.clone(),
},
);
}
Declaration::Rule(rule) => {
self.add_rule(rule, None);
}
Declaration::Fixture(fixture) => {
let name = fixture.name.value.clone();
self.fixtures
.entry(name.clone())
.or_insert_with(|| FixtureCompletion {
name,
entity: fixture.entity.value.clone(),
detail: format!(
"fixture · {} · {} fields",
fixture.entity.value,
fixture.fields.len()
),
});
}
Declaration::Case(_) | Declaration::Invariant(_) => {}
}
}
for declaration in &program.declarations {
match declaration {
Declaration::Fragment(fragment) => {
for rule in &fragment.rules {
self.add_provisional_decision(rule);
}
}
Declaration::Rule(rule) => self.add_provisional_decision(rule),
_ => {}
}
}
}
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: derive_completion_detail(derive),
parameter_names: derive
.parameters
.iter()
.map(|parameter| parameter.name.value.clone())
.collect(),
parameter_types: derive.parameters.iter().map(parameter_type_name).collect(),
return_type: resolved_derive_return_type(derive).cloned(),
});
}
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: match parameter_list(&rule.parameters) {
parameters if parameters.is_empty() => "rule · inferred inputs".to_owned(),
parameters => format!("rule · {parameters}"),
},
parameter_types: rule.parameters.iter().map(parameter_type_name).collect(),
overrides: rule
.override_targets()
.map(|(target, _)| {
if target.value.contains('.') {
target.value.clone()
} else if let Some(fragment_id) = fragment_id {
format!("{fragment_id}.{}", target.value)
} else {
target.value.clone()
}
})
.collect(),
});
}
fn add_provisional_decision(&mut self, rule: &tess::ast::RuleDecl) {
let tess::ast::Effect::Decide {
decision,
arguments,
implicit_arguments,
value,
..
} = &rule.effect
else {
return;
};
if self.decisions.contains_key(&decision.value) {
return;
}
let parameter_types = if *implicit_arguments {
rule.parameters
.iter()
.map(parameter_type_name)
.collect::<Vec<_>>()
} else {
arguments
.iter()
.map(|argument| {
provisional_expr_type(argument, rule, self)
.map_or_else(|| "unknown".to_owned(), |ty| type_name(&ty).to_owned())
})
.collect::<Vec<_>>()
};
let return_type = provisional_expr_type(value, rule, self).unwrap_or(TypeRef::Unknown);
let parameters = if *implicit_arguments {
parameter_list(&rule.parameters)
} else if parameter_types.is_empty() {
String::new()
} else {
format!("({})", parameter_types.join(", "))
};
let detail = if parameters.is_empty() {
format!("inferred decision · {}", type_name(&return_type))
} else {
format!(
"inferred decision · {parameters}: {}",
type_name(&return_type)
)
};
self.decisions.insert(
decision.value.clone(),
DecisionCompletion {
name: decision.value.clone(),
detail,
parameter_types,
return_type,
},
);
}
fn derive<'a>(
&'a self,
name: &str,
declaration: Option<&LexicalDeclaration>,
) -> Option<&'a DeriveCompletion> {
let local = declaration
.and_then(|declaration| declaration.fragment_id.as_deref())
.and_then(|fragment_id| self.derives.get(&format!("{fragment_id}.{name}")));
if local.is_some() {
return local;
}
if is_builtin_callable_name(name) {
return None;
}
unique_leaf_value(&self.derives, name)
}
fn decision(&self, name: &str) -> Option<&DecisionCompletion> {
unique_leaf_value(&self.decisions, 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 leaf_counts = leaf_name_counts(self.derives.keys().map(String::as_str));
let mut visible = BTreeMap::new();
for derive in self
.derives
.values()
.filter(|derive| !is_private_symbol(&derive.name))
{
let is_local = local_prefix
.as_deref()
.and_then(|prefix| derive.name.strip_prefix(prefix))
.is_some_and(|suffix| !suffix.contains('.'));
if !is_local {
let leaf = leaf_name(&derive.name);
let label = if !derive.name.contains('.')
|| (leaf_counts.get(leaf) == Some(&1) && !is_builtin_callable_name(leaf))
{
leaf
} else {
derive.name.as_str()
};
visible.insert(label, derive);
}
}
if let Some(prefix) = local_prefix.as_deref() {
for derive in self
.derives
.values()
.filter(|derive| !is_private_symbol(&derive.name))
{
if let Some(local) = derive
.name
.strip_prefix(prefix)
.filter(|suffix| !suffix.contains('.'))
{
visible.insert(local, derive);
}
}
}
visible.into_iter().collect()
}
fn visible_decisions(&self) -> Vec<(&str, &DecisionCompletion)> {
let leaf_counts = leaf_name_counts(self.decisions.keys().map(String::as_str));
self.decisions
.values()
.filter(|decision| !is_private_symbol(&decision.name))
.map(|decision| {
let leaf = leaf_name(&decision.name);
let label = if !decision.name.contains('.')
|| (leaf_counts.get(leaf) == Some(&1) && !is_builtin_callable_name(leaf))
{
leaf
} else {
decision.name.as_str()
};
(label, decision)
})
.collect()
}
}
fn provisional_expr_type(
expression: &tess::ast::Expr,
rule: &tess::ast::RuleDecl,
catalog: &CompletionCatalog,
) -> Option<TypeRef> {
use tess::ast::{BinaryOp, ExprKind, Literal, NumericLiteral, UnaryOp};
match &expression.kind {
ExprKind::Literal(Literal::Bool(_)) => Some(TypeRef::Bool),
ExprKind::Literal(Literal::Number(NumericLiteral::Int(_))) => Some(TypeRef::Int),
ExprKind::Literal(Literal::Number(NumericLiteral::Decimal(_))) => Some(TypeRef::Decimal),
ExprKind::Literal(Literal::String(_)) => Some(TypeRef::String),
ExprKind::Literal(Literal::Unknown) => None,
ExprKind::Name(name) => rule
.parameters
.iter()
.find(|parameter| parameter.name.value == *name)
.map(parameter_type_ref)
.or_else(|| {
let mut owners = catalog
.variants
.values()
.filter(|variant| variant.name == *name)
.map(|variant| variant.enum_name.clone());
let owner = owners.next()?;
owners.next().is_none().then_some(TypeRef::Named(owner))
}),
ExprKind::Field {
receiver, field, ..
} => {
if let ExprKind::Name(owner) = &receiver.kind {
if catalog
.variants
.contains_key(&(owner.clone(), field.value.clone()))
{
return Some(TypeRef::Named(owner.clone()));
}
}
let TypeRef::Named(owner) = provisional_expr_type(receiver, rule, catalog)? else {
return None;
};
catalog
.entities
.get(&owner)
.and_then(|entity| {
entity
.fields
.iter()
.find(|candidate| candidate.name == field.value)
})
.map(|field| field.ty.clone())
}
ExprKind::Call { callee, .. } => catalog
.derive(&callee.value, None)
.and_then(|derive| derive.return_type.clone())
.or(match callee.value.as_str() {
"date" => Some(TypeRef::Date),
"days" | "hours" | "minutes" => Some(TypeRef::Duration),
_ => None,
}),
ExprKind::Unary { operator, operand } => match operator {
UnaryOp::Not => Some(TypeRef::Bool),
UnaryOp::Negate => provisional_expr_type(operand, rule, catalog),
},
ExprKind::Binary {
left,
operator,
right,
} => {
if matches!(
operator,
BinaryOp::Equal
| BinaryOp::NotEqual
| BinaryOp::Greater
| BinaryOp::GreaterEqual
| BinaryOp::Less
| BinaryOp::LessEqual
| BinaryOp::And
| BinaryOp::Or
) {
return Some(TypeRef::Bool);
}
let left = provisional_expr_type(left, rule, catalog);
let right = provisional_expr_type(right, rule, catalog);
if *operator == BinaryOp::Divide
|| matches!(left, Some(TypeRef::Decimal))
|| matches!(right, Some(TypeRef::Decimal))
{
Some(TypeRef::Decimal)
} else {
left.or(right)
}
}
}
}
fn leaf_name(name: &str) -> &str {
name.rsplit('.').next().unwrap_or(name)
}
fn leaf_name_counts<'a>(names: impl Iterator<Item = &'a str>) -> BTreeMap<&'a str, usize> {
let mut counts = BTreeMap::new();
for name in names.filter(|name| !is_private_symbol(name)) {
*counts.entry(leaf_name(name)).or_default() += 1;
}
counts
}
fn unique_leaf_value<'a, T>(values: &'a BTreeMap<String, T>, name: &str) -> Option<&'a T> {
if let Some(value) = values.get(name) {
return Some(value);
}
if name.contains('.') {
return None;
}
let mut matches = values
.iter()
.filter(|(candidate, _)| !is_private_symbol(candidate) && leaf_name(candidate) == name)
.map(|(_, value)| value);
let value = matches.next()?;
matches.next().is_none().then_some(value)
}
fn is_builtin_callable_name(name: &str) -> bool {
matches!(
name,
"date" | "days" | "hours" | "minutes" | "abs" | "max" | "min"
)
}
fn is_private_symbol(name: &str) -> bool {
name.split('.').any(|segment| segment.starts_with("@pkg_"))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DeclarationKind {
Enum,
Record,
Fn,
Frag,
Rule,
Fixture,
Test,
Assert,
}
#[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,
FixtureEntity,
RecordFieldType,
RecordFieldModifier {
allow_optional: bool,
allow_range: bool,
allow_domain: bool,
},
FragmentClause,
FunctionClause,
RuleClause {
allow_separator: bool,
allow_expression: bool,
},
RuleEffect {
allow_decision: bool,
},
RuleOverride,
DecisionOperator {
allow_not_equal: bool,
},
TestClause {
allow_binding: bool,
},
TestEntity,
TestBindingSource,
TestFixture {
entity: String,
},
TestExpect,
TestField {
entity: String,
used_fields: BTreeSet<String>,
},
TestFieldValue {
entity: String,
field: String,
},
FixtureField {
entity: String,
used_fields: BTreeSet<String>,
},
FixtureFieldValue {
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);
}
let standalone = compile_source(SourceFile::new(
uri.as_str().to_owned(),
document.text.clone(),
))
.program;
if let Some(program) = &standalone {
catalog.add_program(program.ast());
}
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,
qualified_prefix,
qualified_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(
&catalog,
&document.text,
declaration,
cursor,
receiver,
)
.or_else(|| {
matches!(
declaration.kind,
DeclarationKind::Rule | DeclarationKind::Fn
)
.then(|| implicit_receiver_entity(&catalog, 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.as_ref() {
Some(TypeRef::Named(name)) if catalog.entities.contains_key(name) => {
Some(name.clone())
}
_ => None,
})
}),
};
let Some(entity_name) = entity_name else {
return Some(match &receiver {
MemberReceiver::Name(enum_name) if catalog.enums.contains_key(enum_name) => {
enum_member_completion_items(
&index,
&catalog,
enum_name,
&qualified_prefix,
qualified_replace,
)
}
MemberReceiver::Name(_) => qualified_name_completion_items(
&index,
&catalog,
current.as_ref(),
&document.text,
cursor,
&qualified_prefix,
qualified_replace,
),
MemberReceiver::DeriveCall(_) => 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 _ = (fragment_start, fragment_id);
let candidates = match kind {
ProvisionalFragmentKind::Clause => vec![
keyword_candidate("fn", "declare a computed value for this fragment"),
keyword_candidate("rule", "declare a rule for this fragment"),
],
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 bare_assert_decision = lexical
.as_ref()
.filter(|declaration| declaration.kind == DeclarationKind::Assert)
.and_then(|_| document.text.get(line_start..cursor))
.map(str::trim)
.filter(|name| !name.is_empty())
.is_some_and(|name| catalog.decision(name).is_some());
let (context, candidate_prefix, candidate_replace) = if bare_assert_decision {
(
CompletionContext::DecisionCardinality,
"",
Span::new(cursor, cursor),
)
} else {
(
completion_context(
&document.text,
cursor,
line_start,
replace.start,
&line_tokens,
&tokens,
lexical.as_ref(),
),
prefix.as_str(),
replace,
)
};
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| completion_candidate_matches(candidate, candidate_prefix))
.enumerate()
.map(|(order, candidate)| {
let mut item = completion_item(
&index,
candidate_replace,
&candidate.label,
candidate.kind,
&candidate.detail,
);
item.sort_text = Some(format!("{order:04}"));
item
})
.collect()
}
};
Some(items)
}
fn completion_candidate_matches(candidate: &Candidate, prefix: &str) -> bool {
candidate.label.starts_with(prefix)
|| (candidate.kind == CompletionItemKind::FUNCTION
&& leaf_name(&candidate.label).starts_with(prefix))
}
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 enum_member_completion_items(
index: &LineIndex<'_>,
catalog: &CompletionCatalog,
enum_name: &str,
prefix: &str,
replace: Span,
) -> Vec<CompletionItem> {
catalog
.variants
.values()
.filter(|variant| variant.enum_name == enum_name)
.filter_map(|variant| {
let label = format!("{enum_name}.{}", variant.name);
label.starts_with(prefix).then_some((label, variant))
})
.enumerate()
.map(|(order, (label, variant))| {
let mut item = completion_item(
index,
replace,
&label,
CompletionItemKind::ENUM_MEMBER,
&variant.detail,
);
item.sort_text = Some(format!("{order:04}"));
item
})
.collect()
}
fn qualified_name_completion_items(
index: &LineIndex<'_>,
catalog: &CompletionCatalog,
current: Option<&Program>,
text: &str,
cursor: usize,
prefix: &str,
replace: Span,
) -> Vec<CompletionItem> {
let before = &text[..cursor];
let lexed = lex(&SourceFile::new(
"<qualified-completion>",
before.to_owned(),
));
let line_start = before.rfind(['\n', '\r']).map_or(0, |newline| newline + 1);
let line_tokens = lexed
.tokens
.iter()
.filter(|token| token.span.start >= line_start && !layout_or_trivia(&token.kind))
.cloned()
.collect::<Vec<_>>();
let lexical = active_lexical_declaration(text, cursor);
let context = completion_context(
text,
cursor,
line_start,
replace.start,
&line_tokens,
&lexed.tokens,
lexical.as_ref(),
);
let candidate_context = CompletionCandidateContext {
current,
declaration: lexical.as_ref(),
text,
cursor,
line_tokens: &line_tokens,
all_tokens: &lexed.tokens,
};
let mut candidates = candidates_for_context(context, catalog, &candidate_context);
if prefix.contains('.') {
for (label, derive) in catalog.visible_derives(lexical.as_ref()) {
if label != derive.name
&& candidates.iter().any(|candidate| {
candidate.kind == CompletionItemKind::FUNCTION && candidate.label == label
})
{
candidates.push(Candidate::new(
&derive.name,
CompletionItemKind::FUNCTION,
&derive.detail,
));
}
}
for (label, decision) in catalog.visible_decisions() {
if label != decision.name
&& candidates.iter().any(|candidate| {
candidate.kind == CompletionItemKind::FUNCTION && candidate.label == label
})
{
candidates.push(Candidate::new(
&decision.name,
CompletionItemKind::FUNCTION,
&decision.detail,
));
}
}
}
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()
}
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();
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::Override))
&& *index > 0
&& line_tokens[..*index]
.iter()
.any(|token| matches!(token.kind, TokenKind::Colon))
});
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,
});
}
}
if let Some(site) = member_completion_site(text, cursor, &tokens) {
return Some(site);
}
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() || "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,
});
}
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 leading = suffix.len() - suffix.trim_start_matches([' ', '\t']).len();
let result = suffix.get(leading..)?;
if result.starts_with(':') || result.contains([' ', '\t']) || result.ends_with(':') {
return None;
}
return Some(CompletionSite::ProvisionalFragment {
kind: ProvisionalFragmentKind::FunctionReturnType,
prefix: result.to_owned(),
replace: Span::new(cursor - result.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, qualified_start) = if let Some(name) = identifier(tokens[receiver_end]) {
let mut qualified_start = receiver_end;
while qualified_start >= 2
&& matches!(tokens[qualified_start - 1].kind, TokenKind::Dot)
&& identifier(tokens[qualified_start - 2]).is_some()
{
qualified_start -= 2;
}
(MemberReceiver::Name(name.to_owned()), qualified_start)
} 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::Dot)
&& identifier(tokens[callee_start - 2]).is_some()
{
callee_start -= 2;
}
(
MemberReceiver::DeriveCall(qualified_identifier_tokens(
&tokens[callee_start..=callee_end],
)?),
receiver_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),
qualified_prefix: text[tokens[qualified_start].span.start..cursor].to_owned(),
qualified_replace: Span::new(tokens[qualified_start].span.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 => {
let type_prefix = line_tokens
.iter()
.rposition(|token| matches!(token.kind, TokenKind::Colon))
.is_some_and(|colon| qualified_identifier_prefix_tokens(&line_tokens[colon + 1..]));
let still_typing = line_tokens
.last()
.is_some_and(|token| token.span.end == cursor);
let missing_type = line_tokens
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::Colon));
if type_prefix && (missing_type || still_typing) {
CompletionContext::RecordFieldType
} else {
entity_field_modifier_context(line_tokens)
}
}
DeclarationKind::Fn => {
function_completion_context(text, declaration, line_tokens, line_start)
}
DeclarationKind::Frag => CompletionContext::FragmentClause,
DeclarationKind::Rule => {
rule_completion_context(text, line_tokens, tokens, declaration.start, line_start)
}
DeclarationKind::Fixture => {
let Some(entity) = lexical_fixture_entity(tokens, declaration.start) else {
return CompletionContext::None;
};
let used_fields = tokens
.iter()
.filter(|token| token.span.start >= declaration.start)
.collect::<Vec<_>>()
.windows(2)
.filter_map(|window| {
matches!(window[1].kind, TokenKind::Colon)
.then(|| identifier(window[0]).map(str::to_owned))
.flatten()
})
.collect();
if line_tokens.is_empty() {
CompletionContext::FixtureField {
entity,
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::FixtureFieldValue {
entity,
field: field.to_owned(),
}
})
} else {
CompletionContext::None
}
}
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::Enum => CompletionContext::None,
}
}
fn significant_declaration_tokens(tokens: &[Token], start: usize, before: usize) -> Vec<Token> {
tokens
.iter()
.filter(|token| {
token.span.start >= start && token.span.start < before && !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::Fn | DeclarationKind::Assert
) {
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::Fn | DeclarationKind::Assert
) {
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 declaration == DeclarationKind::Fixture
&& tokens.len() >= 2
&& !tokens
.iter()
.any(|token| matches!(token.kind, TokenKind::Colon))
{
return Some(CompletionContext::FixtureEntity);
}
if matches!(
declaration,
DeclarationKind::Rule | DeclarationKind::Fn | DeclarationKind::Assert
) && parameter_type_position(tokens)
{
return Some(CompletionContext::ParameterType {
scalar: declaration == DeclarationKind::Fn,
});
}
if declaration == DeclarationKind::Fn {
let close = tokens
.iter()
.rposition(|token| matches!(token.kind, TokenKind::RightParen))?;
let result = &tokens[close + 1..];
if qualified_identifier_prefix_tokens(result) {
return Some(CompletionContext::ReturnType { allow_entity: true });
}
}
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 Some(open) = tokens.first() else {
return false;
};
if !matches!(open.kind, TokenKind::LeftBracket | TokenKind::LeftParen) {
return false;
}
let mut index = 1;
for bound in 0..2 {
if tokens
.get(index)
.is_some_and(|token| matches!(token.kind, TokenKind::Minus))
{
index += 1;
}
if !tokens
.get(index)
.is_some_and(|token| matches!(token.kind, TokenKind::Number(_)))
{
return false;
}
index += 1;
if bound == 0 {
if !tokens
.get(index)
.is_some_and(|token| matches!(token.kind, TokenKind::Comma))
{
return false;
}
index += 1;
}
}
tokens
.get(index)
.is_some_and(|token| matches!(token.kind, TokenKind::RightBracket | TokenKind::RightParen))
&& index + 1 == 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()
|| qualified_identifier_prefix_tokens(tail)
|| tail.split_first().is_some_and(|(name, ty)| {
identifier(name).is_some() && qualified_identifier_prefix_tokens(ty)
})
}
fn rule_completion_context(
text: &str,
line_tokens: &[Token],
tokens: &[Token],
declaration_start: usize,
line_start: usize,
) -> CompletionContext {
if let Some(block_start) = rule_effect_block_start(text, declaration_start, line_start) {
let allow_decision = !text[block_start..line_start]
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.any(|line| {
!line
.strip_prefix("override")
.is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '\t']))
});
if line_tokens.is_empty() {
return CompletionContext::RuleEffect { allow_decision };
}
if matches!(line_tokens[0].kind, TokenKind::Keyword(Keyword::Override)) {
return if line_tokens.len() == 1 {
CompletionContext::RuleOverride
} else {
CompletionContext::None
};
}
if !allow_decision {
return CompletionContext::None;
}
return rule_effect_completion_context(line_tokens);
}
if line_tokens.is_empty() {
let separator_seen = tokens
.iter()
.filter(|token| token.span.start >= declaration_start && token.span.start < line_start)
.filter(|token| matches!(token.kind, TokenKind::Colon))
.count()
> 1;
return CompletionContext::RuleClause {
allow_separator: !separator_seen,
allow_expression: !separator_seen,
};
}
let Some(separator) = line_tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::Colon))
else {
return CompletionContext::RuleClause {
allow_separator: !expression_expects_operand(line_tokens),
allow_expression: true,
};
};
let effect = &line_tokens[separator + 1..];
if effect.is_empty() {
return CompletionContext::RuleEffect {
allow_decision: true,
};
}
if matches!(effect[0].kind, TokenKind::Keyword(Keyword::Override)) {
return if effect.len() == 1 {
CompletionContext::RuleOverride
} else {
CompletionContext::None
};
}
rule_effect_completion_context(effect)
}
fn rule_effect_completion_context(effect: &[Token]) -> CompletionContext {
let bare_decision = qualified_identifier_tokens(&effect.iter().collect::<Vec<_>>()).is_some();
if completed_call_syntax(effect, 0) || bare_decision {
return CompletionContext::DecisionOperator {
allow_not_equal: false,
};
}
if effect
.iter()
.any(|token| matches!(token.kind, TokenKind::Equal))
{
CompletionContext::Expression
} else {
CompletionContext::RuleEffect {
allow_decision: true,
}
}
}
fn rule_effect_block_start(
text: &str,
declaration_start: usize,
line_start: usize,
) -> Option<usize> {
let current_line = &text[line_start..];
let current_indent = current_line.len() - current_line.trim_start_matches([' ', '\t']).len();
let prefix = &text[declaration_start..line_start];
let mut offset = prefix.len();
for line_with_newline in prefix
.split_inclusive('\n')
.collect::<Vec<_>>()
.into_iter()
.rev()
{
offset -= line_with_newline.len();
let line = line_with_newline.trim_end_matches(['\r', '\n']);
let trimmed = line.trim_start_matches([' ', '\t']);
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let indent = line.len() - trimmed.len();
if indent >= current_indent {
continue;
}
let code = trimmed
.split_once('#')
.map_or(trimmed, |(code, _)| code)
.trim_end();
let anchor = declaration_start + offset;
return (code.ends_with(':')
&& text[declaration_start..anchor]
.bytes()
.filter(|byte| *byte == b':')
.count()
>= 1)
.then_some(anchor + line_with_newline.len());
}
None
}
fn function_completion_context(
text: &str,
declaration: &LexicalDeclaration,
line_tokens: &[Token],
line_start: usize,
) -> CompletionContext {
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,
};
};
if let Some(from) = line_tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::Keyword(Keyword::From)))
{
if qualified_identifier_prefix_tokens(&line_tokens[from + 1..]) {
return qualified_identifier_before(&line_tokens[..from])
.map_or(CompletionContext::None, |entity| {
CompletionContext::TestFixture { entity }
});
}
}
if qualified_identifier_before(line_tokens).is_some()
&& ((identifier(first).is_some()
&& !matches!(
first.kind,
TokenKind::Keyword(Keyword::Expect | Keyword::Let)
))
|| (matches!(first.kind, TokenKind::Keyword(Keyword::Let))
&& line_tokens
.iter()
.any(|token| matches!(token.kind, TokenKind::Equal))))
{
return CompletionContext::TestBindingSource;
}
match first.kind {
TokenKind::Keyword(Keyword::Let)
if line_tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::Equal))
.is_some_and(|equal| {
qualified_identifier_prefix_tokens(&line_tokens[equal + 1..])
}) =>
{
CompletionContext::TestEntity
}
_ if identifier(first).is_some()
&& !matches!(
first.kind,
TokenKind::Keyword(Keyword::Expect | Keyword::Let)
)
&& qualified_identifier_prefix_tokens(line_tokens) =>
{
CompletionContext::TestEntity
}
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 qualified_identifier_tokens(&line_tokens[1..].iter().collect::<Vec<_>>())
.is_some() =>
{
CompletionContext::DecisionOperator {
allow_not_equal: true,
}
}
TokenKind::Keyword(Keyword::Expect)
if qualified_identifier_prefix_tokens(&line_tokens[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 qualified_identifier_before(tokens: &[Token]) -> Option<String> {
let end = tokens.len();
let mut start = end.checked_sub(1)?;
identifier(&tokens[start])?;
while start >= 2
&& matches!(tokens[start - 1].kind, TokenKind::Dot)
&& identifier(&tokens[start - 2]).is_some()
{
start -= 2;
}
qualified_identifier_tokens(&tokens[start..end].iter().collect::<Vec<_>>())
}
fn assert_completion_context(line_tokens: &[Token]) -> CompletionContext {
if line_tokens.is_empty() {
return CompletionContext::AssertBody;
}
if let Some(separator) = line_tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::Colon))
{
let consequence = &line_tokens[separator + 1..];
if consequence.is_empty() {
return CompletionContext::AssertDecision;
}
if completed_call_syntax(consequence, 0) {
return CompletionContext::DecisionOperator {
allow_not_equal: true,
};
}
if qualified_identifier_tokens(&consequence.iter().collect::<Vec<_>>()).is_some() {
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)
|| qualified_identifier_tokens(&line_tokens.iter().collect::<Vec<_>>()).is_some()
{
return CompletionContext::DecisionCardinality;
}
CompletionContext::Expression
}
fn completed_call_syntax(tokens: &[Token], name_index: usize) -> bool {
let Some(open) = tokens[name_index..]
.iter()
.position(|token| matches!(token.kind, TokenKind::LeftParen))
.map(|relative| name_index + relative)
else {
return false;
};
if qualified_identifier_tokens(&tokens[name_index..open].iter().collect::<Vec<_>>()).is_none()
|| !tokens
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::RightParen))
{
return false;
}
let mut depth = 0isize;
for token in &tokens[open..] {
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(
"fixture",
"declare reusable typed test facts",
)),
Some(keyword_candidate("fn", "declare a computed value")),
Some(keyword_candidate("test", "declare a concrete example")),
Some(keyword_candidate("assert", "declare a quantified property")),
]
.into_iter()
.flatten()
.collect(),
CompletionContext::ParameterType { scalar } => {
let mut candidates = Vec::new();
if scalar {
candidates.extend(scalar_type_candidates(catalog));
}
candidates.extend(entity_candidates(catalog));
candidates
}
CompletionContext::ReturnType { allow_entity } => {
let mut candidates = scalar_type_candidates(catalog);
if allow_entity {
candidates.extend(entity_candidates(catalog));
}
candidates
}
CompletionContext::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, 0]",
CompletionItemKind::OPERATOR,
"numeric interval with explicit endpoints",
)
}),
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::FixtureEntity | CompletionContext::TestEntity => {
entity_candidates(catalog)
}
CompletionContext::FragmentClause => vec![
keyword_candidate("fn", "declare a computed value for this fragment"),
keyword_candidate("rule", "declare a rule for this fragment"),
],
CompletionContext::FunctionClause | CompletionContext::Expression => {
expression_candidates(catalog, declaration, text, cursor, line_tokens, all_tokens)
}
CompletionContext::RuleClause {
allow_separator,
allow_expression,
} => {
let mut candidates = Vec::new();
if allow_separator
&& 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 { allow_decision } => {
let mut candidates = vec![keyword_candidate("override", "override another rule")];
if allow_decision {
candidates.extend(scope_compatible_decisions(
catalog,
declaration,
text,
cursor,
));
}
candidates
}
CompletionContext::RuleOverride => {
override_candidates(catalog, current, declaration, text, cursor)
}
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::TestBindingSource => {
vec![keyword_candidate("from", "start from a typed fixture")]
}
CompletionContext::TestFixture { entity } => catalog
.fixtures
.values()
.filter(|fixture| fixture.entity == entity)
.map(|fixture| {
Candidate::new(
&fixture.name,
CompletionItemKind::REFERENCE,
&fixture.detail,
)
})
.collect(),
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::FixtureField {
entity,
used_fields,
} => catalog
.entities
.get(&entity)
.map_or_else(Vec::new, |entity| {
entity
.fields
.iter()
.filter(|field| !used_fields.contains(&field.name))
.map(|field| {
Candidate::new(&field.name, CompletionItemKind::FIELD, &field.detail)
})
.collect()
}),
CompletionContext::FixtureFieldValue { 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::None => Vec::new(),
}
}
fn keyword_candidate(label: &str, detail: &str) -> Candidate {
Candidate::new(label, CompletionItemKind::KEYWORD, detail)
}
fn scope_compatible_decisions(
catalog: &CompletionCatalog,
declaration: Option<&LexicalDeclaration>,
text: &str,
cursor: usize,
) -> Vec<Candidate> {
let Some(declaration) = declaration else {
return Vec::new();
};
let available_types = completion_scope(catalog, text, declaration, cursor)
.into_iter()
.map(|value| value.ty)
.collect::<BTreeSet<_>>();
let infer_rule_scope = declaration.kind == DeclarationKind::Rule
&& lexical_parameter_types(text, declaration, cursor).is_none();
catalog
.visible_decisions()
.into_iter()
.filter(|(_, decision)| {
infer_rule_scope
|| decision
.parameter_types
.iter()
.all(|required| required == "unknown" || available_types.contains(required))
})
.map(|(label, decision)| {
Candidate::new(label, 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| {
completion_scope(catalog, 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) {
let mut candidates = logical_operator_candidates();
if let Some((ty, _)) = operand_type(catalog, declaration, text, cursor, line_tokens) {
candidates.extend(comparison_operator_candidates(catalog, &ty));
candidates.extend(postfix_predicate_candidates(catalog, declaration, &ty));
}
return candidates;
}
if let Some((ty, _)) = operand_type(catalog, declaration, text, cursor, line_tokens) {
let mut candidates = typed_operator_candidates(catalog, &ty);
candidates.extend(postfix_predicate_candidates(catalog, declaration, &ty));
return candidates;
}
return logical_operator_candidates();
}
let mut candidates = vec![
keyword_candidate("O", "boolean O literal"),
keyword_candidate("X", "boolean X literal"),
keyword_candidate("unknown", "unknown optional value"),
];
if let Some(declaration) = declaration {
candidates.extend(
completion_scope(catalog, text, declaration, cursor)
.into_iter()
.map(|value| {
Candidate::new(&value.name, CompletionItemKind::VARIABLE, &value.detail)
}),
);
}
candidates.extend(unique_field_candidates(catalog));
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 postfix_predicate_candidates(
catalog: &CompletionCatalog,
declaration: Option<&LexicalDeclaration>,
operand: &TypeRef,
) -> Vec<Candidate> {
let actual = type_name(operand);
catalog
.visible_derives(declaration)
.into_iter()
.filter(|(_, derive)| {
matches!(derive.return_type, Some(TypeRef::Bool))
&& matches!(
derive.parameter_types.as_slice(),
[expected] if ArgumentExpectation::Exact(expected).accepts(actual)
)
})
.map(|(label, derive)| {
Candidate::new(
label,
CompletionItemKind::FUNCTION,
&format!("postfix predicate · {}", derive.detail),
)
})
.collect()
}
fn typed_value_candidates(
catalog: &CompletionCatalog,
expected: &TypeRef,
allow_unknown: bool,
) -> Vec<Candidate> {
let mut candidates = match expected {
TypeRef::Bool => vec![
keyword_candidate("O", "boolean O literal"),
keyword_candidate("X", "boolean X 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![
Candidate::new("X", CompletionItemKind::OPERATOR, "logical negation"),
Candidate::new("&", CompletionItemKind::OPERATOR, "logical conjunction"),
Candidate::new("|", CompletionItemKind::OPERATOR, "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 comparison_operator_candidates(catalog: &CompletionCatalog, ty: &TypeRef) -> Vec<Candidate> {
typed_operator_candidates(catalog, ty)
.into_iter()
.filter(|candidate| {
matches!(
candidate.label.as_str(),
"=" | "!=" | ">" | ">=" | "<" | "<="
)
})
.collect()
}
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::Ampersand
| TokenKind::Pipe
)
}) {
return true;
}
operand_type(catalog, declaration, text, cursor, tokens)
.is_some_and(|(ty, _)| matches!(ty, TypeRef::Bool | TypeRef::Unknown))
|| tokens.len().checked_sub(1).is_some_and(|right| {
qualified_identifier_ending_at(tokens, right).is_some_and(|name| name.contains('.'))
})
}
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::Colon))?
+ 1
};
let equal = tokens[start..]
.iter()
.position(|token| matches!(token.kind, TokenKind::Equal | TokenKind::NotEqual))?
+ start;
if start >= equal {
return None;
}
let lhs = &tokens[start..equal];
let name = if lhs
.last()
.is_some_and(|token| matches!(token.kind, TokenKind::RightParen))
{
completed_call_name(lhs, lhs.len().checked_sub(1)?)?
} else {
qualified_identifier_tokens(&lhs.iter().collect::<Vec<_>>())?
};
let decision = catalog.decision(&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("unknown") => true,
Self::Exact(expected) => actual == expected || actual == "unknown",
Self::Numeric => matches!(actual, "Int" | "Decimal" | "unknown"),
}
}
}
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
.decision(&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
| TokenKind::Greater
| TokenKind::GreaterEqual
| TokenKind::Less
| TokenKind::LessEqual
)
})?;
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(catalog, text, declaration, cursor, receiver).or_else(
|| {
matches!(
declaration.kind,
DeclarationKind::Rule | DeclarationKind::Fn
)
.then(|| implicit_receiver_entity(catalog, 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.as_ref() {
Some(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 derive.return_type.clone().map(|ty| (ty, 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::O | Keyword::X) => Some((TypeRef::Bool, false)),
TokenKind::Keyword(Keyword::Unknown) => Some((TypeRef::Unknown, true)),
TokenKind::Identifier(name) => {
if let Some(qualified) = qualified_identifier_ending_at(tokens, right) {
if qualified.contains('.') {
if let Some(derive) = catalog.derive(&qualified, declaration) {
return derive.return_type.clone().map(|ty| (ty, false));
}
if let Some(decision) = catalog.decision(&qualified) {
return Some((decision.return_type.clone(), false));
}
}
}
if let Some(value) = declaration.and_then(|declaration| {
completion_scope(catalog, text, declaration, cursor)
.into_iter()
.find(|value| value.name == *name)
}) {
return Some((type_ref_from_name(&value.ty), false));
}
if let Some(field) = unique_field(catalog, name) {
return Some((field.ty.clone(), field.optional));
}
if let Some(derive) = catalog.derive(name, declaration) {
return derive.return_type.clone().map(|ty| (ty, false));
}
if let Some(decision) = catalog.decision(name) {
return Some((decision.return_type.clone(), 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 unique_field<'a>(catalog: &'a CompletionCatalog, name: &str) -> Option<&'a FieldCompletion> {
let mut matches = catalog
.entities
.values()
.flat_map(|entity| &entity.fields)
.filter(|field| field.name == name);
let field = matches.next()?;
matches.next().is_none().then_some(field)
}
fn unique_field_candidates(catalog: &CompletionCatalog) -> Vec<Candidate> {
let mut fields = BTreeMap::<&str, Vec<&FieldCompletion>>::new();
for field in catalog.entities.values().flat_map(|entity| &entity.fields) {
fields.entry(&field.name).or_default().push(field);
}
fields
.into_iter()
.filter_map(|(name, fields)| {
let [field] = fields.as_slice() else {
return None;
};
Some(Candidate::new(
name,
CompletionItemKind::FIELD,
&field.detail,
))
})
.collect()
}
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 declaration_text = text.get(declaration.start..cursor)?;
let lexed = lex(&SourceFile::new(
"<derive-header>",
declaration_text.to_owned(),
));
let tokens = lexed
.tokens
.iter()
.filter(|token| !layout_or_trivia(&token.kind))
.collect::<Vec<_>>();
let colon = tokens
.iter()
.position(|token| matches!(token.kind, TokenKind::Colon))?;
let result_start = if tokens
.get(2)
.is_some_and(|token| matches!(token.kind, TokenKind::LeftParen))
{
tokens[3..colon]
.iter()
.rposition(|token| matches!(token.kind, TokenKind::RightParen))?
+ 4
} else {
2
};
let result = &tokens[result_start..colon];
if result.is_empty() {
return None;
}
let name = qualified_identifier_tokens(result)?;
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,
"" | "unknown" => TypeRef::Unknown,
_ => 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;
}
completed_call_name(tokens, right_paren).is_some_and(|name| catalog.decision(&name).is_some())
}
fn expression_expects_operand(tokens: &[Token]) -> bool {
let Some(last) = tokens.last() else {
return true;
};
matches!(
last.kind,
TokenKind::Keyword(Keyword::Expect)
| TokenKind::Ampersand
| TokenKind::Pipe
| 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::Dot
)
}
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_type_name)
.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::Fn | DeclarationKind::Assert
) {
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, declaration.kind == DeclarationKind::Fn)
.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::Dot) {
return None;
}
value.push('.');
value.push_str(identifier(chunk[1])?);
}
chunks.remainder().is_empty().then_some(value)
}
fn qualified_identifier_prefix_tokens(tokens: &[Token]) -> bool {
let Some(first) = tokens.first() else {
return true;
};
if identifier(first).is_none() {
return false;
}
tokens[1..].iter().enumerate().all(|(index, token)| {
if index % 2 == 0 {
matches!(token.kind, TokenKind::Dot)
} else {
identifier(token).is_some()
}
})
}
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::Dot)
&& 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],
infer_bare_function_types: bool,
) -> 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 !infer_bare_function_types {
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| {
if infer_bare_function_types {
if let Some(value) = qualified_identifier_tokens(parameter) {
if value.contains('.') || is_builtin_type_name(&value) {
let name = value.rsplit('.').next()?.to_owned();
return Some((name, value));
}
return Some((value, "unknown".to_owned()));
}
}
let (name, ty) = parameter.split_first()?;
let name = identifier(name)?.to_owned();
Some((name, qualified_identifier_tokens(ty)?))
})
.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 id = line[..delimiter].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;
}
qualified_identifier_tokens(body)
}
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::Fn => DeclarationKind::Fn,
Keyword::Rule => DeclarationKind::Rule,
Keyword::Fixture => DeclarationKind::Fixture,
Keyword::Test => DeclarationKind::Test,
Keyword::Assert => DeclarationKind::Assert,
_ => return None,
})
}
struct OpenTestBinding {
entity: String,
used_fields: BTreeSet<String>,
}
fn lexical_fixture_entity(tokens: &[Token], start: usize) -> Option<String> {
let header = tokens
.iter()
.filter(|token| token.span.start >= start)
.take_while(|token| !matches!(token.kind, TokenKind::Newline | TokenKind::Eof))
.filter(|token| !layout_or_trivia(&token.kind))
.collect::<Vec<_>>();
if !header
.first()
.is_some_and(|token| matches!(token.kind, TokenKind::Keyword(Keyword::Fixture)))
{
return None;
}
let colon = header
.iter()
.position(|token| matches!(token.kind, TokenKind::Colon))?;
let name = header.get(1)?;
identifier(name)?;
qualified_identifier_tokens(&header[2..colon])
}
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 fixture_start = open.checked_sub(1)?;
while fixture_start >= 2
&& matches!(significant[fixture_start - 1].kind, TokenKind::Dot)
&& identifier(significant[fixture_start - 2]).is_some()
{
fixture_start -= 2;
}
let entity_end = fixture_start
.checked_sub(1)
.filter(|before| matches!(significant[*before].kind, TokenKind::Keyword(Keyword::From)))
.unwrap_or(open);
let mut entity_start = entity_end.checked_sub(1)?;
while entity_start >= 2
&& matches!(significant[entity_start - 1].kind, TokenKind::Dot)
&& identifier(significant[entity_start - 2]).is_some()
{
entity_start -= 2;
}
let entity = qualified_identifier_tokens(&significant[entity_start..entity_end])?;
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(
catalog: &CompletionCatalog,
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
receiver: &str,
) -> Option<String> {
completion_scope(catalog, text, declaration, cursor)
.into_iter()
.find(|value| value.name == receiver)
.and_then(|value| (value.ty != "unknown").then_some(value.ty))
}
fn implicit_receiver_entity(catalog: &CompletionCatalog, receiver: &str) -> Option<String> {
if catalog.entities.contains_key(receiver) {
return Some(receiver.to_owned());
}
let mut matches = catalog.entities.keys().filter(|entity| {
entity
.rsplit('.')
.next()
.is_some_and(|local| local == receiver)
});
let entity = matches.next()?.clone();
matches.next().is_none().then_some(entity)
}
fn lexical_member_scope_active(
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) -> bool {
match declaration.kind {
DeclarationKind::Rule | DeclarationKind::Test | DeclarationKind::Assert => true,
DeclarationKind::Fn => derive_expression_line_active(text, declaration, cursor),
DeclarationKind::Enum
| DeclarationKind::Record
| DeclarationKind::Fixture
| 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);
!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('#') {
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::Fn | DeclarationKind::Assert => {
if let Some(parameters) =
parameter_pairs_from_tokens(&tokens, declaration.kind == DeclarationKind::Fn)
{
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::Fixture
| DeclarationKind::Frag => {}
}
values.into_values().collect()
}
fn completion_scope(
catalog: &CompletionCatalog,
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) -> Vec<ScopedCompletion> {
let mut values = lexical_scope(text, declaration, cursor);
if declaration.kind != DeclarationKind::Fn {
return values;
}
let Some(name) = declaration.name.as_deref() else {
return values;
};
let Some(derive) = catalog.derive(name, Some(declaration)) else {
extend_recovered_implicit_fn_inputs(&mut values, catalog, text, declaration, cursor);
return values;
};
for value in &mut values {
if value.ty != "unknown" {
continue;
}
let Some(index) = derive
.parameter_names
.iter()
.position(|name| name == &value.name)
else {
continue;
};
let Some(ty) = derive
.parameter_types
.get(index)
.filter(|ty| *ty != "unknown")
else {
continue;
};
value.ty.clone_from(ty);
value.detail = format!("variable · {ty} (inferred)");
}
for (name, ty) in derive.parameter_names.iter().zip(&derive.parameter_types) {
if values.iter().any(|value| value.name == *name) {
continue;
}
values.push(ScopedCompletion {
name: name.clone(),
ty: ty.clone(),
detail: format!("variable · {ty} (inferred input)"),
});
}
extend_recovered_implicit_fn_inputs(&mut values, catalog, text, declaration, cursor);
values.sort_by(|left, right| left.name.cmp(&right.name));
values
}
fn extend_recovered_implicit_fn_inputs(
values: &mut Vec<ScopedCompletion>,
catalog: &CompletionCatalog,
text: &str,
declaration: &LexicalDeclaration,
cursor: usize,
) {
let Some(body) = text.get(declaration.start..cursor) else {
return;
};
let Some((_, body)) = body.split_once(['\n', '\r']) else {
return;
};
let lexed = lex(&SourceFile::new("<implicit-fn-scope>", body.to_owned()));
let tokens = lexed
.tokens
.iter()
.filter(|token| !layout_or_trivia(&token.kind))
.collect::<Vec<_>>();
for (index, token) in tokens.iter().enumerate() {
let Some(name) = identifier(token) else {
continue;
};
if tokens
.get(index.wrapping_sub(1))
.is_some_and(|token| matches!(token.kind, TokenKind::Dot))
|| tokens
.get(index + 1)
.is_some_and(|token| matches!(token.kind, TokenKind::Dot | TokenKind::LeftParen))
|| catalog.enums.contains_key(name)
|| catalog.entities.contains_key(name)
|| catalog
.variants
.values()
.any(|variant| variant.name == name)
|| catalog
.derives
.values()
.any(|derive| leaf_name(&derive.name) == name)
|| catalog
.decisions
.values()
.any(|decision| leaf_name(&decision.name) == name)
|| catalog
.entities
.values()
.flat_map(|entity| &entity.fields)
.any(|field| field.name == name)
|| values.iter().any(|value| value.name == name)
{
continue;
}
values.push(ScopedCompletion {
name: name.to_owned(),
ty: "unknown".to_owned(),
detail: "variable · inferred input".to_owned(),
});
}
}
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::Dot))
&& tokens
.get(end + 1)
.and_then(|token| identifier(token))
.is_some()
{
end += 2;
}
let entity_end = end;
if tokens
.get(end)
.is_some_and(|token| matches!(token.kind, TokenKind::Keyword(Keyword::From)))
{
end += 1;
identifier(tokens.get(end)?)?;
end += 1;
while tokens
.get(end)
.is_some_and(|token| matches!(token.kind, TokenKind::Dot))
&& tokens
.get(end + 1)
.and_then(|token| identifier(token))
.is_some()
{
end += 2;
}
}
tokens
.get(end)
.is_some_and(|token| matches!(token.kind, TokenKind::LeftBrace))
.then(|| {
qualified_identifier_tokens(&tokens[start..entity_end]).map(|entity| (entity, end))
})
.flatten()
}
fn callable_detail(kind: &str, parameters: &[Parameter], return_type: &str) -> String {
let parameters = parameter_list(parameters);
if parameters.is_empty() {
format!("{kind} · {return_type}")
} else {
format!("{kind} · {parameters}: {return_type}")
}
}
fn resolved_derive_return_type(derive: &tess::ast::DeriveDecl) -> Option<&TypeRef> {
(!derive.implicit_return_type || derive.return_type.value != TypeRef::Unknown)
.then_some(&derive.return_type.value)
}
fn derive_return_detail(derive: &tess::ast::DeriveDecl) -> String {
match (
derive.implicit_return_type,
resolved_derive_return_type(derive),
) {
(true, Some(return_type)) => format!("{} (inferred)", type_name(return_type)),
(true, None) => "inferred".to_owned(),
(false, Some(return_type)) => type_name(return_type).to_owned(),
(false, None) => "unknown".to_owned(),
}
}
fn derive_completion_detail(derive: &tess::ast::DeriveDecl) -> String {
let parameters = parameter_list(&derive.parameters);
if derive.implicit_return_type {
if parameters.is_empty() {
format!("fn · {}", derive_return_detail(derive))
} else {
format!("fn · {parameters}: {}", derive_return_detail(derive))
}
} else if parameters.is_empty() {
format!("fn · {}", derive_return_detail(derive))
} else {
format!("fn · {parameters} {}", derive_return_detail(derive))
}
}
fn parameter_list(parameters: &[Parameter]) -> String {
let parameters = parameters
.iter()
.filter(|parameter| !parameter.implicit_binding)
.collect::<Vec<_>>();
if parameters.is_empty() {
return String::new();
}
if let [parameter] = parameters.as_slice() {
let implicit_name = parameter.ty.value.rsplit('.').next().unwrap_or_default();
if !parameter.ty.value.is_empty() && parameter.name.value == implicit_name {
return format!("({})", parameter.ty.value);
}
}
format!(
"({})",
parameters
.into_iter()
.map(|parameter| {
if parameter.ty.value.is_empty() {
parameter.name.value.clone()
} else {
format!("{} {}", parameter.name.value, parameter.ty.value)
}
})
.collect::<Vec<_>>()
.join(", ")
)
}
fn parameter_type_name(parameter: &Parameter) -> String {
if parameter.ty.value.is_empty() {
"unknown".to_owned()
} else {
parameter.ty.value.clone()
}
}
fn parameter_type_ref(parameter: &Parameter) -> TypeRef {
type_ref_from_name(¶meter_type_name(parameter))
}
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::Invariant(invariant) => parameter_entity(&invariant.variables, receiver),
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)
.and_then(|parameter| (!parameter.ty.value.is_empty()).then(|| 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 = fragment.id.value.clone();
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, " · {}", render_range_constraint(range));
}
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 is_builtin_type_name(name: &str) -> bool {
matches!(
name,
"Bool" | "Int" | "Decimal" | "String" | "Date" | "Duration"
)
}
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 completion_short_names_ignore_private_package_symbols() {
let derive = |name: &str| DeriveCompletion {
name: name.to_owned(),
detail: "fn 판정 Bool".to_owned(),
parameter_names: Vec::new(),
parameter_types: Vec::new(),
return_type: Some(TypeRef::Bool),
};
let mut catalog = CompletionCatalog::default();
catalog.derives.insert(
"의존성.@pkg_내부.판정".to_owned(),
derive("의존성.@pkg_내부.판정"),
);
catalog
.derives
.insert("공개.판정".to_owned(), derive("공개.판정"));
assert_eq!(
catalog
.visible_derives(None)
.into_iter()
.map(|(label, _)| label)
.collect::<Vec<_>>(),
["판정"]
);
assert_eq!(catalog.derive("판정", None).unwrap().name, "공개.판정");
assert_eq!(
catalog.derive("의존성.@pkg_내부.판정", None).unwrap().name,
"의존성.@pkg_내부.판정"
);
catalog
.derives
.insert("공개.max".to_owned(), derive("공개.max"));
assert!(catalog.derive("max", None).is_none());
assert_eq!(catalog.derive("공개.max", None).unwrap().name, "공개.max");
assert!(
catalog
.visible_derives(None)
.iter()
.any(|(label, _)| *label == "공개.max")
);
}
#[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", "test", "assert"] {
assert!(labels.contains(&keyword), "missing {keyword}: {labels:?}");
}
for non_keyword in [
"module",
"import",
"entity",
"derive",
"decision",
"case",
"invariant",
"source",
"frag",
"text",
"state",
"action",
"transition",
"trace",
"on",
"set",
"when",
"initially",
"always",
"terminates",
"within",
"steps",
"no",
"dead",
"ends",
] {
assert!(
!labels.contains(&non_keyword),
"non-keyword completion leaked: {non_keyword}"
);
}
}
#[test]
fn fixture_completions_follow_the_declared_record_type() {
let declarations = concat!(
"mod m\n",
"record 요청:\n 활성: Bool\n 메모: String?\n\n",
"record 다른요청:\n 값: Int\n\n",
"fixture 기본요청 요청:\n 활성: O\n\n",
"fixture 다른기본값 다른요청:\n 값: 1\n\n",
);
assert_eq!(
labels(&complete_at_marker(&format!(
"{declarations}fixture 새기본값 <caret>"
))),
["다른요청", "요청"]
);
assert_eq!(
labels(&complete_at_marker(&format!(
"{declarations}test 예시:\n 요청 from <caret>"
))),
["기본요청"]
);
assert_eq!(
labels(&complete_at_marker(&format!(
"{declarations}test 예시:\n 요청 <caret>"
))),
["from"]
);
assert_eq!(
labels(&complete_at_marker(&format!(
"{declarations}test 예시:\n 요청 from 기본요청 {{\n <caret>"
))),
["활성", "메모"]
);
assert_eq!(
labels(&complete_at_marker(concat!(
"record 요청:\n 활성: Bool\n 메모: String?\n\n",
"fixture 기본요청 요청:\n <caret>"
))),
["활성", "메모"]
);
}
#[test]
fn fixture_references_participate_in_navigation_and_rename_sets() {
let text = concat!(
"record 요청:\n 활성: Bool\n\n",
"fixture 기본요청 요청:\n 활성: O\n\n",
"test 예시:\n 요청 from 기본요청 {}\n"
);
let uri = Uri::from_str("file:///fixture-navigation.tes").unwrap();
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let documents = BTreeMap::from([(uri.as_str().to_owned(), document)]);
let index = navigation::navigation_index(&uri, &documents[uri.as_str()], &documents)
.expect("recoverable fixture source should produce a navigation index");
let use_offset = text.rfind("기본요청").unwrap() + 1;
let definition = index.definition(&uri, use_offset).unwrap();
let definition_offset = text.find("기본요청").unwrap();
assert_eq!(
definition.range.start,
LineIndex::new(text).position(definition_offset)
);
assert_eq!(index.references(&uri, use_offset, true).len(), 2);
let hover = index.hover(&uri, use_offset).unwrap();
let lsp_types::HoverContents::Markup(markup) = hover.contents else {
panic!("expected markdown fixture hover")
};
assert!(markup.value.contains("fixture `기본요청`"));
}
#[test]
fn qualified_enum_variants_navigate_as_variants() {
let text = concat!(
"enum 결과:\n 무료\n 유료\n\n",
"record 주문:\n 무료: Bool\n\n",
"rule 무료배송:\n",
" 무료: 정책 = 결과.무료\n",
);
let uri = Uri::from_str("file:///qualified-variant-navigation.tes").unwrap();
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let documents = BTreeMap::from([(uri.as_str().to_owned(), document)]);
let index = navigation::navigation_index(&uri, &documents[uri.as_str()], &documents)
.expect("recoverable qualified enum source should produce a navigation index");
let variant_use = text.rfind("결과.무료").unwrap() + "결과.".len() + 1;
let variant_definition = index.definition(&uri, variant_use).unwrap();
assert_eq!(variant_definition.range.start.line, 1);
let owner_use = text.rfind("결과.무료").unwrap() + 1;
let owner_definition = index.definition(&uri, owner_use).unwrap();
assert_eq!(owner_definition.range.start.line, 0);
let field_use = text.find(" 무료:").unwrap() + " ".len() + 1;
let field_definition = index.definition(&uri, field_use).unwrap();
assert_eq!(field_definition.range.start.line, 5);
let hover = index.hover(&uri, variant_use).unwrap();
let lsp_types::HoverContents::Markup(markup) = hover.contents else {
panic!("expected markdown enum variant hover")
};
assert!(markup.value.contains("enum variant `무료`"));
}
#[test]
fn record_field_type_annotations_hover_jump_and_find_references() {
let text = concat!(
"enum 피해자유형:\n 개인\n\n",
"enum 판단방식:\n 직접\n 하위사실\n\n",
"record 표현주장:\n",
" 피해자유형: 피해자유형?\n",
" 사실적시판단방식: 판단방식?\n",
);
let uri = Uri::from_str("file:///record-field-type-navigation.tes").unwrap();
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let documents = BTreeMap::from([(uri.as_str().to_owned(), document)]);
let index = navigation::navigation_index(&uri, &documents[uri.as_str()], &documents)
.expect("record field types should produce a navigation index");
for (type_name, definition_line) in [("피해자유형", 0), ("판단방식", 3)] {
let annotation = text.rfind(&format!("{type_name}?")).unwrap() + 1;
let definition = index.definition(&uri, annotation).unwrap();
assert_eq!(definition.range.start.line, definition_line);
assert_eq!(index.references(&uri, annotation, true).len(), 2);
let hover = index.hover(&uri, annotation).unwrap();
let lsp_types::HoverContents::Markup(markup) = hover.contents else {
panic!("expected markdown type hover")
};
assert!(markup.value.contains(&format!("enum `{type_name}`")));
}
}
#[test]
fn function_hover_distinguishes_inferred_and_explicit_return_types() {
let text = concat!("fn 추론(Int):\n 1\n\n", "fn 명시(Int) Int:\n 1\n",);
let uri = Uri::from_str("file:///function-return-hover.tes").unwrap();
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let documents = BTreeMap::from([(uri.as_str().to_owned(), document)]);
let index = navigation::navigation_index(&uri, &documents[uri.as_str()], &documents)
.expect("recoverable function source should produce a navigation index");
let inferred = index.hover(&uri, text.find("추론").unwrap() + 1).unwrap();
let lsp_types::HoverContents::Markup(inferred) = inferred.contents else {
panic!("expected markdown inferred function hover")
};
assert!(
inferred.value.contains("fn 추론(Int): Int (inferred)"),
"{}",
inferred.value
);
let explicit = index.hover(&uri, text.find("명시").unwrap() + 1).unwrap();
let lsp_types::HoverContents::Markup(explicit) = explicit.contents else {
panic!("expected markdown explicit function hover")
};
assert!(explicit.value.contains("fn 명시(Int) Int"));
assert!(!explicit.value.contains("inferred"));
}
#[test]
fn bare_function_parameters_remain_available_during_recovery() {
let items =
complete_at_marker("mod m\nfn 비율금액(기준금액, 비율):\n 기준금액 * <caret>\n");
let names = labels(&items);
assert!(names.contains(&"기준금액"));
assert!(names.contains(&"비율"));
let items = complete_at_marker(concat!(
"mod m\n",
"fn 미해결(value):\n value\n\n",
"fn 호출(left, right):\n 미해결(<caret>\n",
));
let names = labels(&items);
assert!(names.contains(&"left"));
assert!(names.contains(&"right"));
}
#[test]
fn recovery_navigation_treats_bare_function_parameters_as_locals() {
let text = "fn 미해결(amount, rate):\n amount\n";
let uri = Uri::from_str("file:///bare-parameter-recovery.tes").unwrap();
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let documents = BTreeMap::from([(uri.as_str().to_owned(), document)]);
let index = navigation::navigation_index(&uri, &documents[uri.as_str()], &documents)
.expect("recoverable function should produce a navigation index");
let declaration = text.find("amount").unwrap() + 1;
let hover = index.hover(&uri, declaration).unwrap();
let lsp_types::HoverContents::Markup(markup) = hover.contents else {
panic!("expected markdown parameter hover")
};
assert!(markup.value.contains("parameter `amount`"));
assert!(markup.value.contains("unknown"));
let use_offset = text.rfind("amount").unwrap() + 1;
let definition = index.definition(&uri, use_offset).unwrap();
assert_eq!(
definition.range.start,
LineIndex::new(text).position(text.find("amount").unwrap())
);
assert_eq!(index.references(&uri, use_offset, true).len(), 2);
}
#[test]
fn compiled_inferred_parameter_types_drive_function_completion() {
let declarations = concat!(
"record 정산요청:\n",
" 매출: Decimal\n",
" 비율: Decimal\n\n",
"fn 비율금액(기준금액, 비율):\n",
" 기준금액 * 비율\n\n",
"fn 지급액(매출, 비율):\n",
" 비율금액(매출, 비율)\n\n",
"rule 지급:\n",
" : 지급제안 = 지급액(매출, 비율)\n\n",
);
let items = complete_at_marker(&format!(
"{declarations}rule 미리보기:\n <caret>O: 미리보기제안 = 지급액(매출, 비율)\n"
));
let detail = items
.iter()
.find(|item| item.label == "비율금액")
.and_then(|item| item.detail.as_deref())
.expect("inferred function completion");
assert!(detail.contains("기준금액 Decimal"), "{detail}");
assert!(detail.contains("비율 Decimal"), "{detail}");
assert!(!detail.contains("unknown"), "{detail}");
let items = complete_at_marker(&format!(
"{declarations}fn 미리보기(매출, 비율):\n 비율금액(<caret>매출, 비율)\n\nrule 미리보기사용:\n : 다른제안 = 미리보기(매출, 비율)\n"
));
let names = labels(&items);
assert!(names.contains(&"매출"), "{names:?}");
assert!(names.contains(&"비율"), "{names:?}");
}
#[test]
fn compiled_navigation_shows_inferred_types_without_reclassifying_parameter_names() {
let text = concat!(
"record 정산요청:\n",
" 매출: Decimal\n",
" 비율: Decimal\n\n",
"fn 비율금액(기준금액, 비율):\n",
" 기준금액 * 비율\n\n",
"rule 지급:\n",
" : 지급제안 = 비율금액(매출, 비율)\n",
);
let uri = Uri::from_str("file:///inferred-parameter-navigation.tes").unwrap();
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let documents = BTreeMap::from([(uri.as_str().to_owned(), document)]);
let index = navigation::navigation_index(&uri, &documents[uri.as_str()], &documents)
.expect("compiled standalone function should produce a navigation index");
let function_hover = index
.hover(&uri, text.find("비율금액").unwrap() + 1)
.unwrap();
let lsp_types::HoverContents::Markup(function) = function_hover.contents else {
panic!("expected markdown function hover")
};
assert!(
function.value.contains("기준금액 Decimal"),
"{}",
function.value
);
assert!(
function.value.contains("비율 Decimal"),
"{}",
function.value
);
let parameter_offset = text.find("기준금액").unwrap() + 1;
let parameter_hover = index.hover(&uri, parameter_offset).unwrap();
let lsp_types::HoverContents::Markup(parameter) = parameter_hover.contents else {
panic!("expected markdown parameter hover")
};
assert!(
parameter.value.contains("parameter `기준금액`"),
"{}",
parameter.value
);
assert!(parameter.value.contains("Decimal"), "{}", parameter.value);
}
#[test]
fn inferred_scalar_predicates_complete_after_compatible_operands() {
let text = concat!(
"enum 판단상태:\n 인정\n 부정\n 가정부정\n\n",
"fn 부정됨:\n 상태 = 부정 | 상태 = 가정부정\n\n",
"fn 확인(상태 판단상태):\n 상태 <caret>\n",
);
let items = complete_at_marker(text);
let predicate = items
.iter()
.find(|item| item.label == "부정됨")
.expect("the compatible postfix predicate should be offered");
assert!(
predicate
.detail
.as_deref()
.is_some_and(|detail| detail.contains("postfix predicate")),
"{:?}",
predicate.detail
);
let repeated = complete_at_marker(concat!(
"enum 판단상태:\n 인정\n 부정\n 가정부정\n\n",
"fn 부정됨:\n 상태 = 부정 | 상<caret>\n",
));
assert!(labels(&repeated).contains(&"상태"));
}
#[test]
fn omitted_scalar_fn_inputs_hover_jump_and_find_all_references() {
let text = concat!(
"enum 판단상태:\n 인정\n 부정\n 가정부정\n\n",
"fn 부정됨:\n 상태 = 부정 | 상태 = 가정부정\n",
);
let uri = Uri::from_str("file:///implicit-scalar-navigation.tes").unwrap();
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let documents = BTreeMap::from([(uri.as_str().to_owned(), document)]);
let index = navigation::navigation_index(&uri, &documents[uri.as_str()], &documents)
.expect("compiled inferred scalar function should produce a navigation index");
let first = text.find("상태 =").unwrap();
let second = text.rfind("상태 =").unwrap();
let definition = index.definition(&uri, second + 1).unwrap();
assert_eq!(definition.range.start, LineIndex::new(text).position(first));
assert_eq!(index.references(&uri, second + 1, true).len(), 2);
let hover = index.hover(&uri, second + 1).unwrap();
let lsp_types::HoverContents::Markup(markup) = hover.contents else {
panic!("expected markdown inferred-parameter hover")
};
assert!(
markup.value.contains("parameter `상태`"),
"{}",
markup.value
);
assert!(markup.value.contains("판단상태"), "{}", markup.value);
}
#[test]
fn postfix_predicates_hover_jump_and_find_references() {
let text = concat!(
"enum 판단상태:\n 인정\n 부정\n 가정부정\n\n",
"record 사건:\n 상태: 판단상태\n\n",
"fn 부정됨:\n 상태 = 부정 | 상태 = 가정부정\n\n",
"fn 확인:\n 상태 부정됨\n",
);
let uri = Uri::from_str("file:///postfix-predicate-navigation.tes").unwrap();
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let documents = BTreeMap::from([(uri.as_str().to_owned(), document)]);
let index = navigation::navigation_index(&uri, &documents[uri.as_str()], &documents)
.expect("postfix predicate source should compile for navigation");
let usage = text.rfind("부정됨").unwrap() + 1;
let definition = index.definition(&uri, usage).unwrap();
assert_eq!(
definition.range.start,
LineIndex::new(text).position(text.find("부정됨").unwrap())
);
assert_eq!(index.references(&uri, usage, true).len(), 2);
let hover = index.hover(&uri, usage).unwrap();
let lsp_types::HoverContents::Markup(markup) = hover.contents else {
panic!("expected postfix predicate hover")
};
assert!(markup.value.contains("fn `부정됨`"), "{}", markup.value);
}
#[test]
fn bare_assertion_decisions_navigate_to_the_inferred_decision() {
let text = concat!(
"record 신청:\n",
" 점수: Int\n\n",
"rule 사유규칙:\n",
" : 사유 = \"\"\n\n",
"assert 사유존재:\n",
" 사유?\n",
);
let uri = Uri::from_str("file:///bare-cardinality-navigation.tes").unwrap();
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let documents = BTreeMap::from([(uri.as_str().to_owned(), document)]);
let index = navigation::navigation_index(&uri, &documents[uri.as_str()], &documents)
.expect("bare decision assertion should produce a navigation index");
let definition_offset = text.find(": 사유").unwrap() + ": ".len();
let use_offset = text.rfind(" 사유?").unwrap() + " ".len() + 1;
let definition = index.definition(&uri, use_offset).unwrap();
assert_eq!(
definition.range.start,
LineIndex::new(text).position(definition_offset)
);
assert_eq!(index.references(&uri, use_offset, true).len(), 2);
let hover = index.hover(&uri, use_offset).unwrap();
let lsp_types::HoverContents::Markup(markup) = hover.contents else {
panic!("expected markdown decision hover")
};
assert!(markup.value.contains("inferred decision `사유`"));
}
#[test]
fn unique_short_function_names_complete_and_navigate_to_qualified_declarations() {
let text = concat!(
"record 학생:\n 출석: Int\n\n",
"강의계획서.출석규정:\n 출석 규정 원문.\n\n",
" fn 성적평가가능 Bool:\n 출석 >= 10\n\n",
"fn 수강가능 Bool:\n 성적평가가능\n",
);
let uri = Uri::from_str("file:///short-function-navigation.tes").unwrap();
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let documents = BTreeMap::from([(uri.as_str().to_owned(), document)]);
let index = navigation::navigation_index(&uri, &documents[uri.as_str()], &documents)
.expect("recoverable short function source should produce a navigation index");
let use_offset = text.rfind("성적평가가능").unwrap() + 1;
let definition = index.definition(&uri, use_offset).unwrap();
assert_eq!(
definition.range.start,
LineIndex::new(text).position(text.find("성적평가가능").unwrap())
);
let hover = index.hover(&uri, use_offset).unwrap();
let lsp_types::HoverContents::Markup(markup) = hover.contents else {
panic!("expected markdown short function hover")
};
assert!(
markup
.value
.contains("fn `강의계획서.출석규정.성적평가가능`")
);
assert_eq!(index.references(&uri, use_offset, true).len(), 2);
let completion = complete_at_marker(&text.replace(
"fn 수강가능 Bool:\n 성적평가가능\n",
"rule 수강가능:\n <caret>\n",
));
assert!(
labels(&completion).contains(&"성적평가가능"),
"{:?}",
labels(&completion)
);
assert!(!labels(&completion).contains(&"강의계획서.출석규정.성적평가가능"));
let operators =
complete_at_marker(&text.replace(" 성적평가가능\n", " 성적평가가능 <caret>\n"));
assert_eq!(labels(&operators), ["X", "&", "|", "=", "!="]);
}
#[test]
fn ambiguous_short_functions_do_not_navigate_and_complete_with_full_paths() {
let text = concat!(
"record 학생:\n 출석: Int\n\n",
"강의계획서.출석규정:\n 첫 규정.\n\n",
" fn 성적평가가능 Bool:\n 출석 >= 10\n\n",
"교칙.성적규정:\n 둘째 규정.\n\n",
" fn 성적평가가능 Bool:\n 출석 >= 20\n\n",
"fn 수강가능 Bool:\n 성적평가가능\n",
);
let uri = Uri::from_str("file:///ambiguous-short-function.tes").unwrap();
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let documents = BTreeMap::from([(uri.as_str().to_owned(), document)]);
let index = navigation::navigation_index(&uri, &documents[uri.as_str()], &documents)
.expect("recoverable ambiguous function source should produce a navigation index");
let use_offset = text.rfind("성적평가가능").unwrap() + 1;
assert!(index.definition(&uri, use_offset).is_none());
assert!(index.hover(&uri, use_offset).is_none());
assert!(index.references(&uri, use_offset, true).is_empty());
let completion = complete_at_marker(&text.replace(
"fn 수강가능 Bool:\n 성적평가가능\n",
"rule 수강가능:\n <caret>\n",
));
let completion_labels = labels(&completion);
assert!(
completion_labels.contains(&"강의계획서.출석규정.성적평가가능"),
"{completion_labels:?}"
);
assert!(completion_labels.contains(&"교칙.성적규정.성적평가가능"));
assert!(!completion_labels.contains(&"성적평가가능"));
}
#[test]
fn exact_top_level_and_fragment_local_functions_beat_short_name_fallback() {
let text = concat!(
"record 학생:\n 출석: Int\n\n",
"fn 성적평가가능 Bool:\n 출석 >= 10\n\n",
"강의계획서.출석규정:\n 출석 규정 원문.\n\n",
" fn 성적평가가능 Bool:\n 출석 >= 20\n\n",
" fn 내부판정 Bool:\n 성적평가가능\n\n",
"fn 외부판정 Bool:\n 성적평가가능\n",
);
let uri = Uri::from_str("file:///short-function-precedence.tes").unwrap();
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let documents = BTreeMap::from([(uri.as_str().to_owned(), document)]);
let index = navigation::navigation_index(&uri, &documents[uri.as_str()], &documents)
.expect("recoverable precedence source should produce a navigation index");
let occurrences = text
.match_indices("성적평가가능")
.map(|(offset, _)| offset)
.collect::<Vec<_>>();
assert_eq!(occurrences.len(), 4);
let local_definition = index.definition(&uri, occurrences[2] + 1).unwrap();
assert_eq!(
local_definition.range.start,
LineIndex::new(text).position(occurrences[1])
);
let top_level_definition = index.definition(&uri, occurrences[3] + 1).unwrap();
assert_eq!(
top_level_definition.range.start,
LineIndex::new(text).position(occurrences[0])
);
let completion = complete_at_marker(&text.replace(
"fn 외부판정 Bool:\n 성적평가가능\n",
"rule 외부판정:\n <caret>\n",
));
let completion_labels = labels(&completion);
assert!(completion_labels.contains(&"성적평가가능"));
assert!(completion_labels.contains(&"강의계획서.출석규정.성적평가가능"));
}
#[test]
fn short_rule_override_navigation_uses_the_unique_target() {
let text = concat!(
"record 신청:\n 허용: Bool\n\n",
"법.근거.출석:\n 출석 근거.\n\n",
"법.규칙:\n 적용 규칙.\n\n",
"\n",
" rule 기본:\n 허용: 결과 = O\n\n",
"rule 외부:\n",
"\n",
" : override 기본\n",
);
let uri = Uri::from_str("file:///short-symbol-navigation.tes").unwrap();
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let documents = BTreeMap::from([(uri.as_str().to_owned(), document)]);
let index = navigation::navigation_index(&uri, &documents[uri.as_str()], &documents)
.expect("recoverable short symbol source should produce a navigation index");
let line_index = LineIndex::new(text);
let override_use = text.rfind("기본").unwrap() + 1;
let definition = index.definition(&uri, override_use).unwrap();
assert_eq!(
definition.range.start,
line_index.position(text.find("기본").unwrap())
);
assert_eq!(index.references(&uri, override_use, true).len(), 2);
}
#[test]
fn combined_rule_effects_navigate_both_decision_and_override_targets() {
let text = concat!(
"enum 결과:\n 허용\n 거절\n\n",
"rule 기본:\n : 판정 = 거절\n\n",
"rule 예외:\n",
" O:\n",
" 판정 = 허용\n",
" override 기본\n",
);
let uri = Uri::from_str("file:///combined-effect-navigation.tes").unwrap();
let document = OpenDocument {
text: text.to_owned(),
version: 1,
};
let documents = BTreeMap::from([(uri.as_str().to_owned(), document)]);
let index = navigation::navigation_index(&uri, &documents[uri.as_str()], &documents)
.expect("combined effects should produce a navigation index");
let line_index = LineIndex::new(text);
let override_use = text.rfind("기본").unwrap() + 1;
let override_definition = index.definition(&uri, override_use).unwrap();
assert_eq!(
override_definition.range.start,
line_index.position(text.find("기본").unwrap())
);
let decision_use = text.rfind("판정").unwrap() + 1;
let decision_definition = index.definition(&uri, decision_use).unwrap();
assert_eq!(
decision_definition.range.start,
line_index.position(text.find("판정").unwrap())
);
}
#[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조:\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, 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(concat!(
"record 신청:\n 점수: Int\n\n",
"rule 사유규칙:\n : 사유 = \"\"\n\n",
"assert 사유존재:\n 사유<caret>\n",
));
let cardinality = labels(&cardinality_items);
assert_eq!(cardinality, ["?", "*"]);
let bare_cardinality_items = complete_at_marker(concat!(
"record 신청:\n 점수: Int\n\n",
"rule 사유규칙:\n : 사유 = \"\"\n\n",
"assert 사유존재:\n 사유<caret>\n",
));
assert_eq!(labels(&bare_cardinality_items), ["?", "*"]);
for item in bare_cardinality_items {
let CompletionTextEdit::Edit(edit) = item.text_edit.unwrap() else {
panic!("expected a text edit for a cardinality suffix")
};
assert_eq!(edit.range.start, edit.range.end);
}
}
#[test]
fn four_space_layout_selects_fragment_and_nested_rule_contexts() {
let fragment_level = complete_at_marker(
"record 신청:\n 국내: Bool\n\n법.제1조:\n 원문.\n\n rule 국내:\n : 결과 = O\n\n <caret>\n",
);
assert_eq!(labels(&fragment_level), ["fn", "rule"]);
let rule_level = complete_at_marker(
"record 신청:\n 국내: Bool\n\n법.제1조:\n 원문.\n\n rule 기존:\n : 결과 = O\n\n rule 국내:\n <caret>\n",
);
let labels = labels(&rule_level);
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조:\n 원문.\n\n rule 국내(신청):\n 신청.<caret>\n",
);
assert_eq!(labels(&items), ["국내", "점수"]);
assert!(items[1].detail.as_deref().unwrap().contains("[0, 100]"));
}
#[test]
fn implicit_rule_bindings_complete_members_and_bare_decision_effects() {
let declarations = concat!(
"record 주문:\n 금액: Int [0, 100_000]\n 긴급: Bool\n\n",
"enum 배송결과:\n 무료\n 유료\n\n",
"rule 기본배송:\n : 배송정책 = 유료\n\n",
);
let fields = complete_at_marker(&format!("{declarations}rule 무료배송:\n <caret>\n"));
let field_labels = labels(&fields);
assert!(field_labels.contains(&"금액"));
assert!(field_labels.contains(&"긴급"));
let shadowed_enum = complete_at_marker(&format!(
"{declarations}rule 이름충돌(배송결과 주문):\n 배송결과.<caret>\n"
));
assert_eq!(labels(&shadowed_enum), ["금액", "긴급"]);
let effects = complete_at_marker(&format!(
"{declarations}rule 무료배송:\n 금액 > 50_000: <caret>\n"
));
assert_eq!(labels(&effects), ["override", "배송정책"]);
let values = complete_at_marker(&format!(
"{declarations}rule 무료배송:\n 금액 > 50_000: 배송정책 = <caret>\n"
));
assert_eq!(labels(&values), ["무료", "유료"]);
let qualified_values = complete_at_marker(&format!(
"{declarations}rule 무료배송:\n 금액 > 50_000: 배송정책 = 배송결과.<caret>\n"
));
assert_eq!(
labels(&qualified_values),
["배송결과.무료", "배송결과.유료"]
);
let CompletionTextEdit::Edit(edit) = qualified_values[0].text_edit.as_ref().unwrap() else {
panic!("expected a qualified enum completion edit")
};
assert_eq!(edit.new_text, "배송결과.무료");
let chain_operators = complete_at_marker(&format!(
"{declarations}rule 구간무료:\n 10_000 < 금액 <caret>\n"
));
assert_eq!(
labels(&chain_operators),
[":", "X", "&", "|", "=", "!=", ">", ">=", "<", "<="]
);
}
#[test]
fn rule_effect_block_completion_enforces_one_decision_candidate() {
let declarations = concat!(
"enum 배송결과:\n 무료\n 유료\n\n",
"rule 기본배송:\n : 배송정책 = 유료\n\n",
);
let first_effect = complete_at_marker(&format!(
"{declarations}rule 예외:\n O:\n <caret>\n"
));
assert_eq!(labels(&first_effect), ["override", "배송정책"]);
let next_effect = complete_at_marker(&format!(
"{declarations}rule 예외:\n O:\n 배송정책 = 무료\n <caret>\n"
));
assert_eq!(labels(&next_effect), ["override"]);
let override_target = complete_at_marker(&format!(
"{declarations}rule 예외:\n O:\n 배송정책 = 무료\n override <caret>\n"
));
assert_eq!(labels(&override_target), ["기본배송"]);
}
#[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\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_operators = complete_at_marker(&format!(
"{declarations} fn 국내판정 Bool:\n 국내\n\nrule 외부:\n 법.적용.국내판정 <caret>\n"
));
assert_eq!(labels(&external_operators), [":", "X", "&", "|"]);
let external_calls = complete_at_marker(&format!(
"{declarations} fn 국내판정 Bool:\n 국내\n\nrule 외부:\n <caret>\n"
));
assert!(labels(&external_calls).contains(&"국내판정"));
assert!(!labels(&external_calls).contains(&"법.적용.국내판정"));
let qualified_calls = complete_at_marker(&format!(
"{declarations} fn 국내판정 Bool:\n 국내\n\nrule 외부:\n 법.적용.<caret>\n"
));
assert_eq!(labels(&qualified_calls), ["법.적용.국내판정"]);
let CompletionTextEdit::Edit(edit) = qualified_calls[0].text_edit.as_ref().unwrap() else {
panic!("expected a qualified completion edit")
};
assert_eq!(edit.new_text, "법.적용.국내판정");
let external_member = complete_at_marker(&format!(
"{declarations} fn 그대로(신청) 신청:\n 신청\n\nrule 외부(신청):\n 법.적용.그대로(신청).<caret>\n"
));
assert_eq!(labels(&external_member), ["국내", "점수"]);
let inferred_members = complete_at_marker(&format!(
"{declarations} fn 자동판정(신청):\n 신청.<caret>\n"
));
assert_eq!(labels(&inferred_members), ["국내", "점수"]);
let inferred_boundary = complete_at_marker(&format!(
"{declarations} fn 자동판정(신청):\n 신청.국내\n\n <caret>\n"
));
assert_eq!(labels(&inferred_boundary), ["fn", "rule"]);
}
#[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\nrule 국내결과:\n : 결과 = O\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_source_id() {
let parsed = parse(SourceFile::new(
"detail.tes",
"법.제1조:\n 첫 줄 원문입니다.\n 둘째 줄입니다.\n",
));
let Declaration::Fragment(fragment) = &parsed.program.unwrap().declarations[0] else {
panic!("expected fragment")
};
assert_eq!(
fragment_detail(fragment),
"fragment · 법.제1조 · 첫 줄 원문입니다."
);
}
#[test]
fn nested_rule_recovers_its_fragment_id_after_a_header_comment() {
let text = "법.적용: # 원문 위치\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>")),
["fn", "rule"]
);
}
#[test]
fn incomplete_fragment_items_complete_across_the_raw_boundary() {
let types = complete_at_marker(
"record 신청:\n 국내: Bool\n\nlaw.consumer:\n Consumer.\n\n rule 적용(신<caret>\n",
);
assert_eq!(labels(&types), ["신청"]);
let return_types = complete_at_marker(
"record 신청:\n 국내: Bool\n\nlaw.consumer:\n Consumer.\n\n fn 적용(신청) B<caret>\n",
);
assert_eq!(labels(&return_types), ["Bool"]);
}
#[test]
fn qualified_override_completion_replaces_the_whole_stable_rule_id() {
let items = complete_at_marker(
"record 신청:\n 국내: Bool\n\n법.원문:\n 원문.\n\n rule 기본(신청):\n : 결과(신청) = O\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()),
qualified_prefix: "신청.".to_owned(),
qualified_replace: Span::new(" ".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()),
qualified_prefix: "신청.결".to_owned(),
qualified_replace: Span::new(" ".len(), prefixed_member.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()),
qualified_prefix: ").".to_owned(),
qualified_replace: Span::new(fn_member.len() - ").".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",
"rule 기본규칙(s 학생):\n O: 최종등급(s) = F\n\n",
"rule 다른타입(t 교사):\n O: 최종등급(t) = F\n\n",
);
let items = complete_at_marker(&format!(
"{declarations}rule 현재규칙(s 학생):\n O: <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("inferred decision · (학생): 등급")
);
let items = complete_at_marker(&format!(
"{declarations}rule 현재규칙(s 학생):\n O: 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",
"rule 학생기본(학생):\n : 학생판정(학생) = O\n\n",
"rule 배정기본(t 교사, s 학생):\n : 역순배정(t, s) = O\n\n",
"rule 교실기본(교실):\n : 교실판정(교실) = O\n\n",
);
let items = complete_at_marker(&format!(
"{declarations}rule 배정(s 학생, t 교사):\n O: <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 O: 역순배정(<caret>"
));
assert_eq!(labels(&items), ["t"]);
let items = complete_at_marker(&format!(
"{declarations}rule 배정(s 학생, t 교사):\n O: 역순배정(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",
"rule 안전:\n O: 판정 = O\n\n",
"rule 중복:\n O: 판정 = O\n\n",
"rule 순환A:\n O: override 순환B\n\n",
"rule 순환B:\n O: override 현재\n\n",
"rule 현재:\n",
" O:\n",
" 판정 = O\n",
" override 중복\n",
" 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",
"rule 현재:\n",
" <caret>",
);
let items = complete_at_marker(text);
let expression_labels = labels(&items);
for expected in [
"O",
"X",
"unknown",
"점수",
"통과가능",
"date",
"abs",
"max",
] {
assert!(
expression_labels.contains(&expected),
"missing {expected} in {expression_labels:?}"
);
}
let field = items.iter().find(|item| item.label == "점수").unwrap();
assert_eq!(field.kind, Some(CompletionItemKind::FIELD));
assert_eq!(field.detail.as_deref(), Some("학생 · Int"));
let items = complete_at_marker(&text.replace("<caret>", "O <caret>"));
assert_eq!(labels(&items), [":", "X", "&", "|", "=", "!="]);
}
#[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",
"rule 기본등급:\n : 최종등급 = F\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(concat!(
"mod m\n",
"rule 기본서비스이름:\n : 서비스이름 = \"tess\"\n\n",
"test 예시:\n expect <caret>",
));
assert_eq!(labels(&items), ["서비스이름"]);
let items = complete_at_marker(&format!(
"{declarations}test 예시:\n 학생 {{ 총점: 100, 결석: 0, 등급값: A }}\n expect 최종등급 <caret>"
));
assert_eq!(labels(&items), ["=", "!="]);
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 현재등급 등급:\n 등급값\n\n",
"fn 그대로(c 고객) 고객:\n c\n\n",
);
let items = complete_at_marker(&format!("{declarations}fn 기본등급 등급:\n <caret>"));
let names = labels(&items);
assert_eq!(names.len(), 2);
assert!(names.contains(&"일반"));
assert!(names.contains(&"우수"));
assert!(!names.contains(&"휴면"));
for (header, condition) in [
("rule 정책:", "등급값 = <caret>"),
("rule 정책:", "현재등급 = <caret>"),
("rule 정책(c 고객):", "그대로(c).등급값 = <caret>"),
] {
let items = complete_at_marker(&format!("{declarations}{header}\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 정책:\n 활성 = <caret>"));
assert_eq!(labels(&items), ["O", "X", "unknown"]);
let items = complete_at_marker(&format!("{declarations}rule 정책:\n 등급값 <caret>"));
assert_eq!(labels(&items), ["=", "!="]);
let items = complete_at_marker(&format!("{declarations}rule 정책:\n 금액 <caret>"));
assert_eq!(
labels(&items),
["=", "!=", ">", ">=", "<", "<=", "+", "-", "*", "/"]
);
let items =
complete_at_marker(&format!("{declarations}rule 정책:\n 금액 >= 10 <caret>"));
assert_eq!(
labels(&items),
[":", "X", "&", "|", "=", "!=", ">", ">=", "<", "<="]
);
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_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",
);
let items = complete_at_marker(&format!(
"{declarations}test c:\n let x = 입력 {{\n 필수A: O,\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",
"rule 기본등급:\n : 최종등급 = F\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}fn 그대로(학생) <caret>"));
assert!(labels(&items).contains(&"학생"));
assert!(complete_at_marker(&format!("{declarations}fn 그대로(학생): <caret>")).is_empty());
assert!(
complete_at_marker(&format!("{declarations}fn 그대로(학생): 학<caret>")).is_empty()
);
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 학생.<caret>"
));
assert_eq!(labels(&items), ["점수"]);
let signatures = complete_at_marker(&format!(
"{declarations}fn 추론(Int):\n 1\n\nfn 명시(Int) Int:\n 1\n\nrule 사용(Int):\n <caret>"
));
let inferred = signatures
.iter()
.find(|item| item.label == "추론")
.expect("inferred function completion");
assert_eq!(inferred.detail.as_deref(), Some("fn · (Int): inferred"));
let explicit = signatures
.iter()
.find(|item| item.label == "명시")
.expect("explicit function completion");
assert_eq!(explicit.detail.as_deref(), Some("fn · (Int) Int"));
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",
"rule 기본판정(s 학생, t 교사):\n : 판정(s, t) = O\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}rule 기본선택:\n : 선택 = O\n\nassert 선택수:\n 선택<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 lexical_function_return_recovery_follows_the_single_colon_header() {
for (text, expected) in [
("fn explicit(Int) Decimal:\n 1\n", Some(TypeRef::Decimal)),
("fn inferred(Int):\n 1\n", None),
("fn removed(Int): Decimal:\n 1\n", None),
] {
let declaration = LexicalDeclaration {
kind: DeclarationKind::Fn,
start: 0,
name: None,
fragment_id: None,
};
assert_eq!(
lexical_derive_return_type(text, &declaration, text.len()),
expected,
"header: {text:?}"
);
}
}
#[test]
fn completion_scope_does_not_leak_across_declarations() {
let items = complete_at_marker(concat!(
"mod m\n",
"record 학생:\n 점수: Int\n\n",
"rule 이전:\n O: 결과 = O\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",
"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조:\n",
" 강의계획서 제4조 원문.\n\n",
"학칙.제2조:\n",
" 학칙 제2조 원문.\n\n",
"record 학생:\n",
" 총점: Int [0, 100]\n",
" 결석횟수: Int? [0, 15]\n\n",
"학칙.판정:\n",
" 성적 판정 원문.\n\n",
" rule 등급(s 학생):\n",
" s.결\n",
"\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")
);
}
#[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:\n Eligibility handbook section.\n");
let rules_text = std::fs::read_to_string(&rules).unwrap().replacen(
" enabled: eligibility = Eligible",
" en",
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(" en\n").unwrap() + " en".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"));
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("enabled: eligibility", "missing: eligibility");
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\nfn identity(Int) Int:\n 1\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\nfn identity(Int) Int:\n 1\n"
);
}
}