use std::collections::HashMap;
use std::env;
use std::path::PathBuf;
use std::sync::RwLock;
use tower_lsp::jsonrpc::Result;
use tower_lsp::lsp_types::*;
use tower_lsp::{Client, LanguageServer, LspService, Server};
use tracing::{info, warn};
mod completion;
mod diagnostics;
mod includes;
use diagnostics::QuotationInfo;
use includes::{IncludeResolution, LocalWord};
struct DocumentState {
content: String,
file_path: Option<PathBuf>,
includes: IncludeResolution,
local_words: Vec<LocalWord>,
quotations: Vec<QuotationInfo>,
}
struct SeqLanguageServer {
client: Client,
documents: RwLock<HashMap<String, DocumentState>>,
}
impl SeqLanguageServer {
fn new(client: Client) -> Self {
Self {
client,
documents: RwLock::new(HashMap::new()),
}
}
fn with_document<T>(&self, uri: &str, f: impl FnOnce(&DocumentState) -> T) -> Option<T> {
match self.documents.read() {
Ok(docs) => docs.get(uri).map(f),
Err(e) => {
warn!("documents lock poisoned: {}", e);
None
}
}
}
fn update_document(
&self,
uri: &str,
content: String,
file_path: Option<PathBuf>,
) -> Vec<tower_lsp::lsp_types::Diagnostic> {
let (include_stmts, local_words) = includes::parse_document(&content);
info!(
"Parsed document: {} includes, {} local words, file_path={:?}",
include_stmts.len(),
local_words.len(),
file_path
);
let resolved = includes::resolve_includes(&include_stmts, file_path.as_deref());
info!(
"Document has {} local words, {} included words, {} included unions from {} includes",
local_words.len(),
resolved.words.len(),
resolved.union_names.len(),
include_stmts.len()
);
let (diagnostics, quotations) =
diagnostics::check_document_with_quotations(&content, &resolved, file_path.as_deref());
info!("Found {} quotations with type info", quotations.len());
let state = DocumentState {
content,
file_path,
includes: resolved,
local_words,
quotations,
};
if let Ok(mut docs) = self.documents.write() {
docs.insert(uri.to_string(), state);
}
diagnostics
}
}
#[tower_lsp::async_trait]
impl LanguageServer for SeqLanguageServer {
async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
info!("Seq LSP server initializing");
let inlay_hints_enabled = params
.initialization_options
.as_ref()
.and_then(|opts| opts.get("inlay_hints"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let inlay_hint_provider = if inlay_hints_enabled {
info!("Inlay hints enabled");
Some(OneOf::Right(InlayHintServerCapabilities::Options(
InlayHintOptions {
resolve_provider: Some(false),
work_done_progress_options: Default::default(),
},
)))
} else {
None
};
Ok(InitializeResult {
capabilities: ServerCapabilities {
position_encoding: Some(PositionEncodingKind::UTF8),
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::FULL,
)),
completion_provider: Some(CompletionOptions {
trigger_characters: Some(vec![
" ".to_string(),
"\n".to_string(),
":".to_string(),
".".to_string(),
]),
..Default::default()
}),
hover_provider: Some(HoverProviderCapability::Simple(true)),
definition_provider: Some(OneOf::Left(true)),
document_symbol_provider: Some(OneOf::Left(true)),
code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
inlay_hint_provider,
..Default::default()
},
server_info: Some(ServerInfo {
name: "seq-lsp".to_string(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}),
})
}
async fn initialized(&self, _: InitializedParams) {
info!("Seq LSP server initialized");
self.client
.log_message(MessageType::INFO, "Seq LSP server ready")
.await;
}
async fn shutdown(&self) -> Result<()> {
info!("Seq LSP server shutting down");
Ok(())
}
async fn did_open(&self, params: DidOpenTextDocumentParams) {
let uri = params.text_document.uri;
let text = params.text_document.text;
info!("Document opened: {}", uri);
let file_path = includes::uri_to_path(uri.as_str());
info!("File path: {:?}", file_path);
let diagnostics = self.update_document(uri.as_str(), text, file_path);
self.client
.publish_diagnostics(uri, diagnostics, None)
.await;
}
async fn did_change(&self, params: DidChangeTextDocumentParams) {
let uri = params.text_document.uri;
if let Some(change) = params.content_changes.into_iter().next() {
let text = change.text;
info!("Document changed: {}", uri);
let file_path = self
.with_document(uri.as_str(), |s| s.file_path.clone())
.flatten();
let diagnostics = self.update_document(uri.as_str(), text, file_path);
self.client
.publish_diagnostics(uri, diagnostics, None)
.await;
}
}
async fn did_close(&self, params: DidCloseTextDocumentParams) {
let uri = params.text_document.uri;
info!("Document closed: {}", uri);
if let Ok(mut docs) = self.documents.write() {
docs.remove(uri.as_str());
}
self.client.publish_diagnostics(uri, vec![], None).await;
}
async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> {
let uri = params.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
let Some((word, local_words, included_words, quotations)) =
self.with_document(uri.as_str(), |state| {
(
get_word_at_position(&state.content, position),
state.local_words.clone(),
state.includes.words.clone(),
state.quotations.clone(),
)
})
else {
return Ok(None);
};
let line = position.line as usize;
let col = position.character as usize;
for q in "ations {
if q.span.contains(line, col) {
let type_str = format_quotation_type(&q.inferred_type);
return Ok(Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: format!("```seq\n{}\n```\n\n*Quotation*", type_str),
}),
range: Some(Range {
start: Position {
line: q.span.start_line as u32,
character: q.span.start_column as u32,
},
end: Position {
line: q.span.end_line as u32,
character: q.span.end_column as u32,
},
}),
}));
}
}
let Some(word) = word else {
return Ok(None);
};
if let Some(hover) = lookup_word_hover(&word, &local_words, &included_words) {
return Ok(Some(hover));
}
Ok(None)
}
async fn goto_definition(
&self,
params: GotoDefinitionParams,
) -> Result<Option<GotoDefinitionResponse>> {
let uri = params.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
let Some((word, local_words, included_words)) = self.with_document(uri.as_str(), |state| {
(
get_word_at_position(&state.content, position),
state.local_words.clone(),
state.includes.words.clone(),
)
}) else {
return Ok(None);
};
let Some(word) = word else {
return Ok(None);
};
for local in &local_words {
if local.name == word {
let location = Location {
uri: uri.clone(),
range: make_definition_range(local.start_line, &local.name),
};
return Ok(Some(GotoDefinitionResponse::Scalar(location)));
}
}
for included in &included_words {
if included.name == word
&& let Some(ref file_path) = included.file_path
&& file_path.exists()
&& let Ok(file_uri) = Url::from_file_path(file_path)
{
let location = Location {
uri: file_uri,
range: make_definition_range(included.start_line, &included.name),
};
return Ok(Some(GotoDefinitionResponse::Scalar(location)));
}
}
Ok(None)
}
async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
let uri = params.text_document.uri;
let range = params.range;
let Some((content, file_path)) = self.with_document(uri.as_str(), |state| {
(state.content.clone(), state.file_path.clone())
}) else {
return Ok(None);
};
let actions = diagnostics::get_code_actions(&content, range, &uri, file_path.as_deref());
if actions.is_empty() {
Ok(None)
} else {
Ok(Some(
actions
.into_iter()
.map(CodeActionOrCommand::CodeAction)
.collect(),
))
}
}
async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
let uri = params.text_document_position.text_document.uri;
let position = params.text_document_position.position;
let (line_prefix, included_words, local_words) = self
.with_document(uri.as_str(), |state| {
let prefix = state
.content
.lines()
.nth(position.line as usize)
.map(|line| {
let end = (position.character as usize).min(line.len());
line[..end].to_string()
});
(
prefix,
state.includes.words.clone(),
state.local_words.clone(),
)
})
.unwrap_or_default();
let context = line_prefix
.as_ref()
.map(|prefix| completion::CompletionContext {
line_prefix: prefix,
included_words: &included_words,
local_words: &local_words,
});
let items = completion::get_completions(context);
Ok(Some(CompletionResponse::Array(items)))
}
async fn document_symbol(
&self,
params: DocumentSymbolParams,
) -> Result<Option<DocumentSymbolResponse>> {
let uri = params.text_document.uri;
let Some((local_words, content)) = self.with_document(uri.as_str(), |state| {
(state.local_words.clone(), state.content.clone())
}) else {
return Ok(None);
};
let line_lengths: Vec<u32> = content.lines().map(|l| l.len() as u32).collect();
let symbols: Vec<DocumentSymbol> = local_words
.iter()
.map(|word| {
let end_char = line_lengths.get(word.end_line).copied().unwrap_or(0);
#[allow(deprecated)]
DocumentSymbol {
name: word.name.clone(),
detail: None,
kind: SymbolKind::FUNCTION,
tags: None,
deprecated: None,
range: Range {
start: Position {
line: word.start_line as u32,
character: 0,
},
end: Position {
line: word.end_line as u32,
character: end_char,
},
},
selection_range: Range {
start: Position {
line: word.start_line as u32,
character: 0,
},
end: Position {
line: word.start_line as u32,
character: word.name.len() as u32 + 2, },
},
children: None,
}
})
.collect();
Ok(Some(DocumentSymbolResponse::Nested(symbols)))
}
async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
let uri = params.text_document.uri;
let range = params.range;
let Some((content, local_words, included_words)) =
self.with_document(uri.as_str(), |state| {
(
state.content.clone(),
state.local_words.clone(),
state.includes.words.clone(),
)
})
else {
return Ok(None);
};
let mut hints = Vec::new();
for (line_num, line) in content.lines().enumerate() {
let line_num = line_num as u32;
if line_num < range.start.line || line_num > range.end.line {
continue;
}
let trimmed = line.trim();
if trimmed.starts_with('\\') || trimmed.starts_with("include ") {
continue;
}
for hint in find_word_calls_in_line(line, line_num, &local_words, &included_words) {
hints.push(hint);
}
}
Ok(Some(hints))
}
}
fn find_word_calls_in_line(
line: &str,
line_num: u32,
local_words: &[includes::LocalWord],
included_words: &[includes::IncludedWord],
) -> Vec<InlayHint> {
let mut hints = Vec::new();
if line.trim().starts_with(':') {
return hints;
}
let is_word_char = |c: char| c.is_alphanumeric() || "-_>?!<+=*/:".contains(c);
let mut in_string = false;
let mut word_start: Option<usize> = None;
for (i, c) in line.char_indices() {
if c == '"' {
in_string = !in_string;
continue;
}
if in_string {
continue;
}
if is_word_char(c) {
if word_start.is_none() {
word_start = Some(i);
}
} else if let Some(start) = word_start {
let word = &line[start..i];
if let Some(hint) =
make_inlay_hint_for_word(word, line_num, i as u32, local_words, included_words)
{
hints.push(hint);
}
word_start = None;
}
}
if let Some(start) = word_start {
let word = &line[start..];
if let Some(hint) = make_inlay_hint_for_word(
word,
line_num,
line.len() as u32,
local_words,
included_words,
) {
hints.push(hint);
}
}
hints
}
fn make_type_inlay_hint(line: u32, end_col: u32, effect: &seqc::Effect) -> InlayHint {
let effect_str = completion::format_effect(effect);
InlayHint {
position: Position {
line,
character: end_col,
},
label: InlayHintLabel::String(format!(": {}", effect_str)),
kind: Some(InlayHintKind::TYPE),
text_edits: None,
tooltip: None,
padding_left: Some(false),
padding_right: Some(true),
data: None,
}
}
fn make_inlay_hint_for_word(
word: &str,
line: u32,
end_col: u32,
local_words: &[includes::LocalWord],
included_words: &[includes::IncludedWord],
) -> Option<InlayHint> {
let skip_words = [
"if", "else", "then", "do", "loop", "begin", "dup", "drop", "swap", "over", "rot", "nip",
"tuck", "pick", "+", "-", "*", "/", "=", "<", ">", "<=", ">=", "<>", "and", "or", "not",
"true", "false",
];
if skip_words.contains(&word) {
return None;
}
for local in local_words {
if local.name == word
&& let Some(ref effect) = local.effect
{
return Some(make_type_inlay_hint(line, end_col, effect));
}
}
for included in included_words {
if included.name == word
&& let Some(ref effect) = included.effect
{
return Some(make_type_inlay_hint(line, end_col, effect));
}
}
for (name, effect) in seqc::builtins::builtin_signatures() {
if name == word {
return Some(make_type_inlay_hint(line, end_col, &effect));
}
}
None
}
fn get_word_at_position(content: &str, position: Position) -> Option<String> {
let line = content.lines().nth(position.line as usize)?;
let col = position.character as usize;
let is_word_char = |c: char| c.is_alphanumeric() || "-_>?!<+=*/:".contains(c);
let start = line[..col.min(line.len())]
.char_indices()
.rev()
.find(|(_, c)| !is_word_char(*c))
.map(|(i, _)| i + 1)
.unwrap_or(0);
let end = line[col.min(line.len())..]
.char_indices()
.find(|(_, c)| !is_word_char(*c))
.map(|(i, _)| col + i)
.unwrap_or(line.len());
if start >= end {
return None;
}
Some(line[start..end].to_string())
}
fn make_definition_range(start_line: usize, name: &str) -> Range {
Range {
start: Position {
line: start_line as u32,
character: 0,
},
end: Position {
line: start_line as u32,
character: (name.chars().count() + 2) as u32,
},
}
}
fn format_quotation_type(typ: &seqc::types::Type) -> String {
completion::format_type(typ)
}
fn lookup_word_hover(
word: &str,
local_words: &[includes::LocalWord],
included_words: &[includes::IncludedWord],
) -> Option<Hover> {
use tower_lsp::lsp_types::{HoverContents, MarkupContent, MarkupKind};
for local in local_words {
if local.name == word {
let effect = local
.effect
.as_ref()
.map(completion::format_effect)
.unwrap_or_else(|| "( ? )".to_string());
return Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: format!(
"```seq\n: {} {}\n```\n\n*Defined in this file*",
word, effect
),
}),
range: None,
});
}
}
for included in included_words {
if included.name == word {
let effect = included
.effect
.as_ref()
.map(completion::format_effect)
.unwrap_or_else(|| "( ? )".to_string());
return Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: format!(
"```seq\n: {} {}\n```\n\n*From {}*",
word, effect, included.source
),
}),
range: None,
});
}
}
for (name, effect) in seqc::builtins::builtin_signatures() {
if name == word {
let signature = completion::format_effect(&effect);
let doc = seqc::builtins::builtin_doc(word).unwrap_or("");
let doc_section = if doc.is_empty() {
String::new()
} else {
format!("\n\n{}", doc)
};
return Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: format!(
"```seq\n{} {}\n```{}\n\n*Built-in*",
word, signature, doc_section
),
}),
range: None,
});
}
}
None
}
#[tokio::main]
async fn main() {
let args: Vec<String> = env::args().collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("seq-lsp {}", env!("CARGO_PKG_VERSION"));
return;
}
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("seq_lsp=info".parse().unwrap()),
)
.with_writer(std::io::stderr)
.init();
info!("Starting Seq LSP server");
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let (service, socket) = LspService::new(SeqLanguageServer::new);
Server::new(stdin, stdout, socket).serve(service).await;
}