diff options
Diffstat (limited to 'support/texlab/src/features')
70 files changed, 9361 insertions, 0 deletions
diff --git a/support/texlab/src/features/build.rs b/support/texlab/src/features/build.rs new file mode 100644 index 0000000000..c6a670528f --- /dev/null +++ b/support/texlab/src/features/build.rs @@ -0,0 +1,133 @@ +use std::{ + io::{self, BufRead, BufReader, Read}, + path::Path, + process::{Command, Stdio}, + thread, +}; + +use cancellation::CancellationToken; +use crossbeam_channel::Sender; +use encoding_rs_io::DecodeReaderBytesBuilder; +use lsp_types::TextDocumentIdentifier; +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; + +use crate::Options; + +use super::FeatureRequest; + +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildParams { + pub text_document: TextDocumentIdentifier, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize_repr, Deserialize_repr)] +#[repr(i32)] +pub enum BuildStatus { + Success = 0, + Error = 1, + Failure = 2, + Cancelled = 3, +} + +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildResult { + pub status: BuildStatus, +} + +pub fn build_document( + request: FeatureRequest<BuildParams>, + _cancellation_token: &CancellationToken, + log_sender: Sender<String>, +) -> BuildResult { + let document = request + .subset + .documents + .iter() + .find(|document| { + if let Some(data) = document.data.as_latex() { + data.extras.has_document_environment + } else { + false + } + }) + .map(|document| document.as_ref()) + .unwrap_or_else(|| request.main_document()); + + if document.uri.scheme() != "file" { + return BuildResult { + status: BuildStatus::Failure, + }; + } + log::info!("Building document {}", document.uri.as_str()); + + let options = { request.context.options.read().unwrap().clone() }; + + let status = match build_internal(&document.uri.to_file_path().unwrap(), &options, log_sender) { + Ok(true) => BuildStatus::Success, + Ok(false) => BuildStatus::Error, + Err(why) => { + log::error!("Failed to execute textDocument/build: {}", why); + BuildStatus::Failure + } + }; + BuildResult { status } +} + +fn build_internal(path: &Path, options: &Options, log_sender: Sender<String>) -> io::Result<bool> { + let build_dir = options + .root_directory + .as_ref() + .map(AsRef::as_ref) + .or_else(|| path.parent()) + .unwrap(); + + let args: Vec<_> = options + .build + .args() + .into_iter() + .map(|arg| replace_placeholder(arg, path)) + .collect(); + + let mut process = Command::new(options.build.executable()) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(build_dir) + .spawn()?; + + track_output(process.stdout.take().unwrap(), log_sender.clone()); + track_output(process.stderr.take().unwrap(), log_sender.clone()); + + if !options.build.is_continuous { + process.wait().map(|status| status.success()) + } else { + Ok(true) + } +} + +fn replace_placeholder(arg: String, file: &Path) -> String { + if arg.starts_with('"') || arg.ends_with('"') { + arg + } else { + arg.replace("%f", &file.to_string_lossy()) + } +} + +fn track_output(output: impl Read + Send + 'static, sender: Sender<String>) { + let reader = BufReader::new( + DecodeReaderBytesBuilder::new() + .encoding(Some(encoding_rs::UTF_8)) + .utf8_passthru(true) + .strip_bom(true) + .build(output), + ); + thread::spawn(move || { + for line in reader.lines() { + sender.send(line.unwrap()).unwrap(); + } + }); +} diff --git a/support/texlab/src/features/completion.rs b/support/texlab/src/features/completion.rs new file mode 100644 index 0000000000..13c7aad22b --- /dev/null +++ b/support/texlab/src/features/completion.rs @@ -0,0 +1,567 @@ +mod acronym_ref; +mod argument; +mod begin_command; +mod citation; +mod color; +mod color_model; +mod component_command; +mod component_environment; +mod entry_type; +mod field; +mod glossary_ref; +mod import; +mod include; +mod label; +mod theorem; +mod tikz_library; +mod types; +mod user_command; +mod user_environment; +mod util; + +use cancellation::CancellationToken; +use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; +use lsp_types::{ + CompletionItem, CompletionList, CompletionParams, CompletionTextEdit, Documentation, + InsertTextFormat, MarkupContent, MarkupKind, TextEdit, +}; +use rustc_hash::FxHashSet; + +use crate::{ + syntax::{bibtex, latex, CstNode}, + LineIndexExt, +}; + +use self::{ + acronym_ref::complete_acronyms, + argument::complete_arguments, + begin_command::complete_begin_command, + citation::complete_citations, + color::complete_colors, + color_model::complete_color_models, + component_command::complete_component_commands, + component_environment::complete_component_environments, + entry_type::complete_entry_types, + field::complete_fields, + glossary_ref::complete_glossary_entries, + import::complete_imports, + include::complete_includes, + label::complete_labels, + theorem::complete_theorem_environments, + tikz_library::complete_tikz_libraries, + types::{InternalCompletionItem, InternalCompletionItemData}, + user_command::complete_user_commands, + user_environment::complete_user_environments, + util::{adjust_kind, component_detail, image_documentation}, +}; + +pub use self::types::CompletionItemData; + +use super::{ + cursor::{Cursor, CursorContext}, + lsp_kinds::Structure, + FeatureRequest, +}; + +pub const COMPLETION_LIMIT: usize = 50; + +pub fn complete( + request: FeatureRequest<CompletionParams>, + cancellation_token: &CancellationToken, +) -> Option<CompletionList> { + let mut items = Vec::new(); + let context = CursorContext::new(request); + log::debug!("[Completion] Cursor: {:?}", context.cursor); + complete_entry_types(&context, &mut items, cancellation_token); + complete_fields(&context, &mut items, cancellation_token); + complete_arguments(&context, &mut items, cancellation_token); + complete_citations(&context, &mut items, cancellation_token); + complete_imports(&context, &mut items, cancellation_token); + complete_colors(&context, &mut items, cancellation_token); + complete_color_models(&context, &mut items, cancellation_token); + complete_acronyms(&context, &mut items, cancellation_token); + complete_glossary_entries(&context, &mut items, cancellation_token); + complete_includes(&context, &mut items, cancellation_token); + complete_labels(&context, &mut items, cancellation_token); + complete_tikz_libraries(&context, &mut items, cancellation_token); + complete_component_environments(&context, &mut items, cancellation_token); + complete_theorem_environments(&context, &mut items, cancellation_token); + complete_user_environments(&context, &mut items, cancellation_token); + complete_begin_command(&context, &mut items, cancellation_token); + complete_component_commands(&context, &mut items, cancellation_token); + complete_user_commands(&context, &mut items, cancellation_token); + + cancellation_token.result().ok()?; + + let mut items = dedup(items); + preselect(&context, &mut items); + score(&context, &mut items); + + items.sort_by_key(|item| (!item.preselect, -item.score.unwrap_or(std::i64::MIN + 1))); + let items: Vec<_> = items + .into_iter() + .take(COMPLETION_LIMIT) + .filter(|item| item.score.is_some()) + .map(|item| convert_internal_items(&context, item)) + .enumerate() + .map(|(i, item)| append_sort_text(item, i)) + .collect(); + + let is_incomplete = if context + .request + .context + .client_info + .lock() + .unwrap() + .as_ref() + .map(|info| info.name.as_str()) + .unwrap_or_default() + == "Visual Studio Code" + { + true + } else { + items.len() >= COMPLETION_LIMIT + }; + + Some(CompletionList { + is_incomplete, + items, + }) +} + +fn dedup(items: Vec<InternalCompletionItem>) -> Vec<InternalCompletionItem> { + let mut labels = FxHashSet::default(); + let mut insert = vec![false; items.len()]; + for (i, item) in items.iter().enumerate() { + insert[i] = labels.insert(item.data.label()); + } + items + .into_iter() + .enumerate() + .filter(|(i, _)| insert[*i]) + .map(|(_, item)| item) + .collect() +} + +fn score(context: &CursorContext<CompletionParams>, items: &mut Vec<InternalCompletionItem>) { + let pattern = match &context.cursor { + Cursor::Latex(token) if token.kind().is_command_name() => token.text().trim_end(), + Cursor::Latex(token) if token.kind() == latex::WORD => token.text(), + Cursor::Latex(_) => "", + Cursor::Bibtex(token) if token.kind().is_type() => token.text(), + Cursor::Bibtex(token) if token.kind() == bibtex::WORD => token.text(), + Cursor::Bibtex(token) if token.kind() == bibtex::COMMAND_NAME => token.text().trim_end(), + Cursor::Bibtex(_) => "", + Cursor::Nothing => "", + }; + + let file_pattern = pattern.split('/').last().unwrap(); + let matcher = SkimMatcherV2::default().ignore_case(); + for item in items { + item.score = match &item.data { + InternalCompletionItemData::EntryType { ty } => { + matcher.fuzzy_match(&ty.name, &pattern[1..]) + } + InternalCompletionItemData::Field { field } => { + matcher.fuzzy_match(&field.name, &pattern) + } + InternalCompletionItemData::Argument { name, .. } => { + matcher.fuzzy_match(name, &pattern) + } + InternalCompletionItemData::BeginCommand => matcher.fuzzy_match("begin", &pattern[1..]), + InternalCompletionItemData::Citation { key, .. } => matcher.fuzzy_match(&key, &pattern), + InternalCompletionItemData::ComponentCommand { name, .. } => { + matcher.fuzzy_match(name, &pattern[1..]) + } + InternalCompletionItemData::ComponentEnvironment { name, .. } => { + matcher.fuzzy_match(name, &pattern) + } + InternalCompletionItemData::Class { name } => matcher.fuzzy_match(&name, &pattern), + InternalCompletionItemData::Package { name } => matcher.fuzzy_match(&name, &pattern), + InternalCompletionItemData::Color { name } => matcher.fuzzy_match(&name, &pattern), + InternalCompletionItemData::ColorModel { name } => matcher.fuzzy_match(&name, &pattern), + InternalCompletionItemData::Acronym { name } => matcher.fuzzy_match(&name, &pattern), + InternalCompletionItemData::GlossaryEntry { name } => { + matcher.fuzzy_match(&name, &pattern) + } + InternalCompletionItemData::File { name } => matcher.fuzzy_match(&name, file_pattern), + InternalCompletionItemData::Directory { name } => { + matcher.fuzzy_match(&name, file_pattern) + } + InternalCompletionItemData::Label { name, .. } => matcher.fuzzy_match(&name, &pattern), + InternalCompletionItemData::UserCommand { name } => { + matcher.fuzzy_match(&name, &pattern[1..]) + } + InternalCompletionItemData::UserEnvironment { name } => { + matcher.fuzzy_match(&name, &pattern) + } + InternalCompletionItemData::PgfLibrary { name } => matcher.fuzzy_match(&name, &pattern), + InternalCompletionItemData::TikzLibrary { name } => { + matcher.fuzzy_match(&name, &pattern) + } + }; + } +} + +fn preselect( + context: &CursorContext<CompletionParams>, + items: &mut [InternalCompletionItem], +) -> Option<()> { + let name = context.cursor.as_latex()?; + let group = latex::CurlyGroupWord::cast(name.parent())?; + let end = latex::End::cast(group.syntax().parent()?)?; + let environment = latex::Environment::cast(end.syntax().parent()?)?; + let name = environment.begin()?.name()?.word()?.text(); + + for item in items { + if item.data.label() == name { + item.preselect = true; + } + } + Some(()) +} + +fn convert_internal_items( + context: &CursorContext<CompletionParams>, + item: InternalCompletionItem, +) -> CompletionItem { + let range = context + .request + .main_document() + .line_index + .line_col_lsp_range(item.range); + + let mut new_item = match item.data { + InternalCompletionItemData::EntryType { ty } => { + let text_edit = TextEdit::new(range, (&ty.name).into()); + let kind = Structure::Entry(ty.category).completion_kind(); + CompletionItem { + label: (&ty.name).into(), + kind: Some(adjust_kind(&context.request, kind)), + documentation: ty.documentation.as_ref().map(|doc| { + Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value: doc.into(), + }) + }), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + data: Some(serde_json::to_value(CompletionItemData::EntryType).unwrap()), + ..CompletionItem::default() + } + } + InternalCompletionItemData::Field { field } => { + let text_edit = TextEdit::new(range, (&field.name).into()); + CompletionItem { + label: (&field.name).into(), + kind: Some(adjust_kind( + &context.request, + Structure::Field.completion_kind(), + )), + documentation: Some(Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value: (&field.documentation).into(), + })), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + data: Some(serde_json::to_value(CompletionItemData::FieldName).unwrap()), + ..CompletionItem::default() + } + } + InternalCompletionItemData::Argument { name, image } => { + let text_edit = TextEdit::new(range, name.into()); + CompletionItem { + label: name.into(), + kind: Some(adjust_kind( + &context.request, + Structure::Argument.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::Argument).unwrap()), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + documentation: image + .and_then(|image| image_documentation(&context.request, &name, image)), + ..CompletionItem::default() + } + } + InternalCompletionItemData::BeginCommand => { + if context + .request + .context + .client_capabilities + .lock() + .unwrap() + .text_document + .as_ref() + .and_then(|cap| cap.completion.as_ref()) + .and_then(|cap| cap.completion_item.as_ref()) + .and_then(|cap| cap.snippet_support) + == Some(true) + { + CompletionItem { + kind: Some(adjust_kind( + &context.request, + Structure::Snippet.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::CommandSnippet).unwrap()), + insert_text: Some("begin{$1}\n\t$0\n\\end{$1}".into()), + insert_text_format: Some(InsertTextFormat::Snippet), + ..CompletionItem::new_simple("begin".into(), component_detail(&[])) + } + } else { + let text_edit = TextEdit::new(range, "begin".to_string()); + CompletionItem { + kind: Some(adjust_kind( + &context.request, + Structure::Command.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::Command).unwrap()), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::new_simple("begin".to_string(), component_detail(&[])) + } + } + } + InternalCompletionItemData::Citation { uri, key, text, ty } => { + let text_edit = TextEdit::new(range, key.to_string()); + CompletionItem { + label: key.to_string(), + kind: Some(adjust_kind(&context.request, ty.completion_kind())), + filter_text: Some(text.clone()), + sort_text: Some(text), + data: Some( + serde_json::to_value(CompletionItemData::Citation { + uri: uri.as_ref().clone(), + key: key.into(), + }) + .unwrap(), + ), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::default() + } + } + InternalCompletionItemData::ComponentCommand { + name, + image, + glyph, + file_names, + } => { + let detail = glyph.map_or_else( + || component_detail(file_names), + |glyph| format!("{}, {}", glyph, component_detail(file_names)), + ); + let documentation = + image.and_then(|img| image_documentation(&context.request, &name, img)); + let text_edit = TextEdit::new(range, name.to_string()); + CompletionItem { + kind: Some(adjust_kind( + &context.request, + Structure::Command.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::Command).unwrap()), + documentation, + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::new_simple(name.to_string(), detail) + } + } + InternalCompletionItemData::ComponentEnvironment { name, file_names } => { + let text_edit = TextEdit::new(range, name.to_string()); + CompletionItem { + kind: Some(adjust_kind( + &context.request, + Structure::Environment.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::Environment).unwrap()), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::new_simple(name.to_string(), component_detail(file_names)) + } + } + InternalCompletionItemData::Class { name } => { + let text_edit = TextEdit::new(range, name.to_string()); + CompletionItem { + label: name.into(), + kind: Some(adjust_kind( + &context.request, + Structure::Class.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::Class).unwrap()), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::default() + } + } + InternalCompletionItemData::Package { name } => { + let text_edit = TextEdit::new(range, name.to_string()); + CompletionItem { + label: name.into(), + kind: Some(adjust_kind( + &context.request, + Structure::Package.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::Package).unwrap()), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::default() + } + } + InternalCompletionItemData::Color { name } => { + let text_edit = TextEdit::new(range, name.into()); + CompletionItem { + label: name.into(), + kind: Some(adjust_kind( + &context.request, + Structure::Color.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::Color).unwrap()), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::default() + } + } + InternalCompletionItemData::ColorModel { name } => { + let text_edit = TextEdit::new(range, name.into()); + CompletionItem { + label: name.into(), + kind: Some(adjust_kind( + &context.request, + Structure::ColorModel.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::ColorModel).unwrap()), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::default() + } + } + InternalCompletionItemData::Acronym { name } => { + let text_edit = TextEdit::new(range, name.to_string()); + CompletionItem { + label: name.into(), + kind: Some(adjust_kind( + &context.request, + Structure::GlossaryEntry.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::Acronym).unwrap()), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::default() + } + } + InternalCompletionItemData::GlossaryEntry { name } => { + let text_edit = TextEdit::new(range, name.to_string()); + CompletionItem { + label: name.into(), + kind: Some(adjust_kind( + &context.request, + Structure::GlossaryEntry.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::GlossaryEntry).unwrap()), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::default() + } + } + InternalCompletionItemData::File { name } => { + let text_edit = TextEdit::new(range, name.to_string()); + CompletionItem { + label: name.into(), + kind: Some(adjust_kind( + &context.request, + Structure::File.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::File).unwrap()), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::default() + } + } + InternalCompletionItemData::Directory { name } => { + let text_edit = TextEdit::new(range, name.to_string()); + CompletionItem { + label: name.into(), + kind: Some(adjust_kind( + &context.request, + Structure::Folder.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::Folder).unwrap()), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::default() + } + } + InternalCompletionItemData::Label { + name, + kind, + header, + footer, + text, + } => { + let text_edit = TextEdit::new(range, name.to_string()); + CompletionItem { + label: name.into(), + kind: Some(adjust_kind(&context.request, kind.completion_kind())), + detail: header, + documentation: footer.map(Documentation::String), + sort_text: Some(text.clone()), + filter_text: Some(text), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + data: Some(serde_json::to_value(CompletionItemData::Label).unwrap()), + ..CompletionItem::default() + } + } + InternalCompletionItemData::UserCommand { name } => { + let detail = "user-defined".into(); + let text_edit = TextEdit::new(range, name.to_string()); + CompletionItem { + kind: Some(adjust_kind( + &context.request, + Structure::Command.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::Command).unwrap()), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::new_simple(name.into(), detail) + } + } + InternalCompletionItemData::UserEnvironment { name } => { + let detail = "user-defined".into(); + let text_edit = TextEdit::new(range, name.to_string()); + CompletionItem { + kind: Some(adjust_kind( + &context.request, + Structure::Environment.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::Environment).unwrap()), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::new_simple(name.into(), detail) + } + } + InternalCompletionItemData::PgfLibrary { name } => { + let text_edit = TextEdit::new(range, name.into()); + CompletionItem { + label: name.into(), + kind: Some(adjust_kind( + &context.request, + Structure::PgfLibrary.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::PgfLibrary).unwrap()), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::default() + } + } + InternalCompletionItemData::TikzLibrary { name } => { + let text_edit = TextEdit::new(range, name.into()); + CompletionItem { + label: name.into(), + kind: Some(adjust_kind( + &context.request, + Structure::TikzLibrary.completion_kind(), + )), + data: Some(serde_json::to_value(CompletionItemData::TikzLibrary).unwrap()), + text_edit: Some(CompletionTextEdit::Edit(text_edit)), + ..CompletionItem::default() + } + } + }; + new_item.preselect = Some(item.preselect); + new_item +} + +fn append_sort_text(mut item: CompletionItem, index: usize) -> CompletionItem { + let sort_prefix = format!("{:0>2}", index); + match &item.sort_text { + Some(sort_text) => { + item.sort_text = Some(format!("{} {}", sort_prefix, sort_text)); + } + None => { + item.sort_text = Some(sort_prefix); + } + }; + item +} diff --git a/support/texlab/src/features/completion/acronym_ref.rs b/support/texlab/src/features/completion/acronym_ref.rs new file mode 100644 index 0000000000..9d8bcc6c5f --- /dev/null +++ b/support/texlab/src/features/completion/acronym_ref.rs @@ -0,0 +1,169 @@ +use cancellation::CancellationToken; +use cstree::TextRange; +use lsp_types::CompletionParams; + +use crate::{ + features::cursor::CursorContext, + syntax::{latex, CstNode}, +}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_acronyms<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + cancellation_token.result().ok()?; + + let token = context.cursor.as_latex()?; + let group = latex::CurlyGroupWord::cast(token.parent()) + .filter(|group| context.is_inside_latex_curly(group))?; + + latex::AcronymReference::cast(group.syntax().parent()?)?; + + let range = if token.kind() == latex::WORD { + token.text_range() + } else { + TextRange::empty(context.offset) + }; + + for document in &context.request.subset.documents { + if let Some(data) = document.data.as_latex() { + for name in data + .root + .descendants() + .filter_map(latex::AcronymDefinition::cast) + .filter_map(|node| node.name()) + .filter_map(|name| name.word()) + .map(|name| name.text()) + { + items.push(InternalCompletionItem::new( + range, + InternalCompletionItemData::Acronym { name }, + )); + } + } + } + Some(()) +} + +#[cfg(test)] +mod tests { + use cstree::TextRange; + + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_acronyms(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_acronyms(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_simple() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\newacronym[longplural={Frames per Second}]{fpsLabel}{FPS}{Frame per Second}\n\\acrshort{f}")]) + .main("main.tex") + .line(1) + .character(11) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_acronyms(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(87.into(), 88.into())); + } + } + + #[test] + fn test_empty() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\newacronym[longplural={Frames per Second}]{fpsLabel}{FPS}{Frame per Second}\n\\acrshort{}")]) + .main("main.tex") + .line(1) + .character(10) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_acronyms(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(87.into(), 87.into())); + } + } + + #[test] + fn test_after_group() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\newacronym[longplural={Frames per Second}]{fpsLabel}{FPS}{Frame per Second}\n\\acrshort{}")]) + .main("main.tex") + .line(1) + .character(11) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_acronyms(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_open_brace() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\newacronym[longplural={Frames per Second}]{fpsLabel}{FPS}{Frame per Second}\n\\acrshort{f")]) + .main("main.tex") + .line(1) + .character(11) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_acronyms(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(87.into(), 88.into())); + } + } +} diff --git a/support/texlab/src/features/completion/argument.rs b/support/texlab/src/features/completion/argument.rs new file mode 100644 index 0000000000..e2637e026f --- /dev/null +++ b/support/texlab/src/features/completion/argument.rs @@ -0,0 +1,189 @@ +use cancellation::CancellationToken; +use cstree::TextRange; +use lsp_types::CompletionParams; + +use crate::{ + component_db::COMPONENT_DATABASE, + features::cursor::CursorContext, + syntax::{latex, CstNode}, +}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_arguments<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + cancellation_token.result().ok()?; + + let token = context.cursor.as_latex()?; + + let range = if token.kind() == latex::WORD { + token.text_range() + } else { + TextRange::empty(context.offset) + }; + + let group = latex::CurlyGroup::cast(token.parent()) + .or_else(|| token.parent().parent().and_then(latex::CurlyGroup::cast)) + .filter(|group| context.is_inside_latex_curly(group))?; + + let command = latex::GenericCommand::cast(group.syntax().parent()?)?; + + let index = command + .syntax() + .children() + .filter_map(latex::CurlyGroup::cast) + .position(|g| g.syntax().text_range() == group.syntax().text_range())?; + + let command_name = &command.name()?.text()[1..]; + + for component in COMPONENT_DATABASE.linked_components(&context.request.subset) { + for component_command in component + .commands + .iter() + .filter(|command| command.name == command_name) + { + for (_, param) in component_command + .parameters + .iter() + .enumerate() + .filter(|(i, _)| *i == index) + { + for arg in ¶m.0 { + let item = InternalCompletionItem::new( + range, + InternalCompletionItemData::Argument { + name: &arg.name, + image: arg.image.as_deref(), + }, + ); + items.push(item); + } + } + } + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use cstree::TextRange; + + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_arguments(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_arguments(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_latex_simple() { + let request = FeatureTester::builder() + .files(vec![("main.tex", r#"\mathbb{}\usepackage{amsfonts}"#)]) + .main("main.tex") + .line(0) + .character(8) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_arguments(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(8.into(), 8.into())); + } + } + + #[test] + fn test_latex_word() { + let request = FeatureTester::builder() + .files(vec![("main.tex", r#"\mathbb{foo}\usepackage{amsfonts}"#)]) + .main("main.tex") + .line(0) + .character(8) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_arguments(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(8.into(), 11.into())); + } + } + + #[test] + fn test_latex_open_brace() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\mathbb{ \\usepackage{amsfonts}")]) + .main("main.tex") + .line(0) + .character(8) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_arguments(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(8.into(), 8.into())); + } + } + + #[test] + fn test_latex_open_brace_second() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\mathbb{}{\\usepackage{amsfonts}")]) + .main("main.tex") + .line(0) + .character(10) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_arguments(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } +} diff --git a/support/texlab/src/features/completion/begin_command.rs b/support/texlab/src/features/completion/begin_command.rs new file mode 100644 index 0000000000..ebd272711e --- /dev/null +++ b/support/texlab/src/features/completion/begin_command.rs @@ -0,0 +1,84 @@ +use cancellation::CancellationToken; +use lsp_types::CompletionParams; + +use crate::features::cursor::CursorContext; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_begin_command( + context: &CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem>, + cancellation_token: &CancellationToken, +) -> Option<()> { + cancellation_token.result().ok()?; + let range = context.cursor.command_range(context.offset)?; + + items.push(InternalCompletionItem::new( + range, + InternalCompletionItemData::BeginCommand, + )); + Some(()) +} + +#[cfg(test)] +mod tests { + use cstree::TextRange; + + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_begin_command(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_begin_command(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_latex_simple() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\")]) + .main("main.tex") + .line(0) + .character(1) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_begin_command(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(1.into(), 1.into())); + } + } +} diff --git a/support/texlab/src/features/completion/citation.rs b/support/texlab/src/features/completion/citation.rs new file mode 100644 index 0000000000..6df3364099 --- /dev/null +++ b/support/texlab/src/features/completion/citation.rs @@ -0,0 +1,244 @@ +use std::sync::Arc; + +use cancellation::CancellationToken; +use cstree::TextRange; +use lsp_types::CompletionParams; +use once_cell::sync::Lazy; +use regex::Regex; + +use crate::{ + features::{cursor::CursorContext, lsp_kinds::Structure}, + syntax::{ + bibtex::{self, HasType}, + latex, CstNode, + }, + BibtexEntryTypeCategory, Document, LANGUAGE_DATA, +}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_citations<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + cancellation_token.result().ok()?; + let token = context.cursor.as_latex()?; + + let range = if token.kind() == latex::WORD { + token.text_range() + } else { + TextRange::empty(context.offset) + }; + + check_citation(context, token).or_else(|| check_acronym(token))?; + for document in &context.request.subset.documents { + if let Some(data) = document.data.as_bibtex() { + for entry in data.root.children().filter_map(bibtex::Entry::cast) { + cancellation_token.result().ok()?; + if let Some(item) = make_item(document, entry, range) { + items.push(item); + } + } + } + } + + Some(()) +} + +fn check_citation( + context: &CursorContext<CompletionParams>, + token: &latex::SyntaxToken, +) -> Option<()> { + let group = latex::CurlyGroupWordList::cast(token.parent()) + .filter(|group| context.is_inside_latex_curly(group))?; + latex::Citation::cast(group.syntax().parent()?)?; + Some(()) +} + +fn check_acronym(token: &latex::SyntaxToken) -> Option<()> { + let pair = token.ancestors().find_map(latex::KeyValuePair::cast)?; + let mut key_words = pair.key()?.words(); + if key_words.next()?.text() != "cite" || key_words.next().is_some() { + return None; + } + + latex::AcronymDeclaration::cast(pair.syntax().parent()?.parent()?.parent()?)?; + Some(()) +} + +fn make_item<'a>( + document: &'a Document, + entry: bibtex::Entry<'a>, + range: TextRange, +) -> Option<InternalCompletionItem<'a>> { + let key = entry.key()?.text(); + let ty = LANGUAGE_DATA + .find_entry_type(&entry.ty()?.text()[1..]) + .map(|ty| Structure::Entry(ty.category)) + .unwrap_or_else(|| Structure::Entry(BibtexEntryTypeCategory::Misc)); + + let entry_code = entry.syntax().text().to_string(); + let text = format!( + "{} {}", + key, + WHITESPACE_REGEX + .replace_all( + &entry_code + .replace('{', "") + .replace('}', "") + .replace(',', " ") + .replace("=", " "), + " " + ) + .trim(), + ); + + Some(InternalCompletionItem::new( + range, + InternalCompletionItemData::Citation { + uri: Arc::clone(&document.uri), + key: key.into(), + text, + ty, + }, + )) +} + +static WHITESPACE_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new("\\s+").unwrap()); + +#[cfg(test)] +mod tests { + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_citations(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_citations(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_latex_simple() { + let request = FeatureTester::builder() + .files(vec![ + ("main.tex", "\\addbibresource{main.bib}\n\\cite{}"), + ("main.bib", "@article{foo,}"), + ]) + .main("main.tex") + .line(1) + .character(6) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_citations(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(32.into(), 32.into())); + } + } + + #[test] + fn test_latex_open_brace() { + let request = FeatureTester::builder() + .files(vec![ + ("main.tex", "\\addbibresource{main.bib}\n\\cite{"), + ("main.bib", "@article{foo,}"), + ]) + .main("main.tex") + .line(1) + .character(6) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_citations(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(32.into(), 32.into())); + } + } + + #[test] + fn test_latex_open_brace_second() { + let request = FeatureTester::builder() + .files(vec![ + ("main.tex", "\\addbibresource{main.bib}\n\\cite{foo,a"), + ("main.bib", "@article{foo,}"), + ]) + .main("main.tex") + .line(1) + .character(10) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_citations(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(36.into(), 37.into())); + } + } + + #[test] + fn test_latex_acronym() { + let request = FeatureTester::builder() + .files(vec![ + ( + "main.tex", + "\\addbibresource{main.bib}\n\\DeclareAcronym{foo}{cite={\n}}", + ), + ("main.bib", "@article{foo,}"), + ]) + .main("main.tex") + .line(2) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_citations(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(54.into(), 54.into())); + } + } +} diff --git a/support/texlab/src/features/completion/color.rs b/support/texlab/src/features/completion/color.rs new file mode 100644 index 0000000000..0e94af15fe --- /dev/null +++ b/support/texlab/src/features/completion/color.rs @@ -0,0 +1,122 @@ +use cancellation::CancellationToken; +use cstree::TextRange; +use lsp_types::CompletionParams; + +use crate::{ + features::cursor::CursorContext, + syntax::{latex, CstNode}, + LANGUAGE_DATA, +}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_colors<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + cancellation_token.result().ok()?; + + let token = context.cursor.as_latex()?; + let range = if token.kind() == latex::WORD { + token.text_range() + } else { + TextRange::empty(context.offset) + }; + + let group = latex::CurlyGroupWord::cast(token.parent()) + .filter(|group| context.is_inside_latex_curly(group))?; + latex::ColorReference::cast(group.syntax().parent()?)?; + + for name in &LANGUAGE_DATA.colors { + items.push(InternalCompletionItem::new( + range, + InternalCompletionItemData::Color { name }, + )); + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use cstree::TextRange; + + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_colors(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_colors(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_latex_simple() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\color{}")]) + .main("main.tex") + .line(0) + .character(7) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_colors(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(7.into(), 7.into())); + } + } + + #[test] + fn test_latex_open_brace() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\color{")]) + .main("main.tex") + .line(0) + .character(7) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_colors(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(7.into(), 7.into())); + } + } +} diff --git a/support/texlab/src/features/completion/color_model.rs b/support/texlab/src/features/completion/color_model.rs new file mode 100644 index 0000000000..f7f09b6367 --- /dev/null +++ b/support/texlab/src/features/completion/color_model.rs @@ -0,0 +1,188 @@ +use cancellation::CancellationToken; +use cstree::TextRange; +use lsp_types::CompletionParams; + +use crate::{ + features::cursor::CursorContext, + syntax::{latex, CstNode}, +}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +const MODEL_NAMES: &[&str] = &["gray", "rgb", "RGB", "HTML", "cmyk"]; + +pub fn complete_color_models<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + cancellation_token.result().ok()?; + + let token = context.cursor.as_latex()?; + let range = if token.kind() == latex::WORD { + token.text_range() + } else { + TextRange::empty(context.offset) + }; + + check_color_definition(context, token) + .or_else(|| check_color_definition_set(context, token))?; + + for name in MODEL_NAMES { + items.push(InternalCompletionItem::new( + range, + InternalCompletionItemData::ColorModel { name }, + )); + } + + Some(()) +} + +fn check_color_definition( + context: &CursorContext<CompletionParams>, + token: &latex::SyntaxToken, +) -> Option<()> { + let group = latex::CurlyGroupWord::cast(token.parent()) + .filter(|group| context.is_inside_latex_curly(group))?; + let definition = latex::ColorDefinition::cast(group.syntax().parent()?)?; + definition + .model() + .filter(|model| model.syntax().text_range() == group.syntax().text_range())?; + Some(()) +} + +fn check_color_definition_set( + context: &CursorContext<CompletionParams>, + token: &latex::SyntaxToken, +) -> Option<()> { + let group = latex::CurlyGroupWordList::cast(token.parent()) + .filter(|group| context.is_inside_latex_curly(group))?; + let definition = latex::ColorSetDefinition::cast(group.syntax().parent()?)?; + definition + .model_list() + .filter(|model| model.syntax().text_range() == group.syntax().text_range())?; + Some(()) +} + +#[cfg(test)] +mod tests { + use cstree::TextRange; + + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_color_models(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_color_models(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_definition_simple() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\definecolor{foo}{}")]) + .main("main.tex") + .line(0) + .character(18) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_color_models(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(18.into(), 18.into())); + } + } + + #[test] + fn test_definition_open_brace() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\definecolor{foo}{")]) + .main("main.tex") + .line(0) + .character(18) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_color_models(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(18.into(), 18.into())); + } + } + + #[test] + fn test_definition_set_simple() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\definecolorset{}")]) + .main("main.tex") + .line(0) + .character(16) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_color_models(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(16.into(), 16.into())); + } + } + + #[test] + fn test_definition_set_open_brace() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\definecolorset{")]) + .main("main.tex") + .line(0) + .character(16) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_color_models(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(16.into(), 16.into())); + } + } +} diff --git a/support/texlab/src/features/completion/component_command.rs b/support/texlab/src/features/completion/component_command.rs new file mode 100644 index 0000000000..aba5a78597 --- /dev/null +++ b/support/texlab/src/features/completion/component_command.rs @@ -0,0 +1,177 @@ +use cancellation::CancellationToken; +use lsp_types::CompletionParams; + +use crate::{component_db::COMPONENT_DATABASE, features::cursor::CursorContext}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_component_commands<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + cancellation_token.result().ok()?; + + let range = context.cursor.command_range(context.offset)?; + + for component in COMPONENT_DATABASE.linked_components(&context.request.subset) { + cancellation_token.result().ok()?; + for command in &component.commands { + items.push(InternalCompletionItem::new( + range, + InternalCompletionItemData::ComponentCommand { + name: &command.name, + image: command.image.as_deref(), + glyph: command.glyph.as_deref(), + file_names: &component.file_names, + }, + )); + } + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use cstree::TextRange; + + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_component_commands(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_component_commands(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_latex_simple() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\")]) + .main("main.tex") + .line(0) + .character(1) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_component_commands(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(1.into(), 1.into())); + } + } + + #[test] + fn test_latex_simple_before() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_component_commands(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_latex_simple_package() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\\n\\usepackage{lipsum}")]) + .main("main.tex") + .line(0) + .character(1) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_component_commands(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in &actual_items { + assert_eq!(item.range, TextRange::new(1.into(), 1.into())); + } + + assert!(actual_items + .iter() + .any(|item| item.data.label() == "lipsum")); + } + + #[test] + fn test_latex_simple_existing() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\foo")]) + .main("main.tex") + .line(0) + .character(2) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_component_commands(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(1.into(), 4.into())); + } + } + + #[test] + fn test_bibtex_simple() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@a{b,c={\\ }}")]) + .main("main.bib") + .line(0) + .character(9) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_component_commands(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(9.into(), 10.into())); + } + } +} diff --git a/support/texlab/src/features/completion/component_environment.rs b/support/texlab/src/features/completion/component_environment.rs new file mode 100644 index 0000000000..fca9d03780 --- /dev/null +++ b/support/texlab/src/features/completion/component_environment.rs @@ -0,0 +1,174 @@ +use cancellation::CancellationToken; +use cstree::TextRange; +use lsp_types::CompletionParams; + +use crate::{ + component_db::COMPONENT_DATABASE, + features::cursor::CursorContext, + syntax::{latex, CstNode}, +}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_component_environments<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + cancellation_token.result().ok()?; + + let token = context.cursor.as_latex()?; + let group = latex::CurlyGroupWord::cast(token.parent()) + .filter(|group| context.is_inside_latex_curly(group))?; + if !matches!(group.syntax().parent()?.kind(), latex::BEGIN | latex::END) { + return None; + } + + let range = if token.kind() == latex::WORD { + token.text_range() + } else { + TextRange::empty(context.offset) + }; + + for component in COMPONENT_DATABASE.linked_components(&context.request.subset) { + cancellation_token.result().ok()?; + for name in &component.environments { + items.push(InternalCompletionItem::new( + range, + InternalCompletionItemData::ComponentEnvironment { + name, + file_names: &component.file_names, + }, + )); + } + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use cstree::TextRange; + + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_component_environments(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_component_environments(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_simple() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\begin{")]) + .main("main.tex") + .line(0) + .character(7) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_component_environments(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(7.into(), 7.into())); + } + } + + #[test] + fn test_simple_end() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\begin{a}\n\\end{")]) + .main("main.tex") + .line(1) + .character(5) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_component_environments(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(15.into(), 15.into())); + } + } + + #[test] + fn test_simple_class() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\begin{}\n\\documentclass{article}")]) + .main("main.tex") + .line(0) + .character(7) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_component_environments(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in &actual_items { + assert_eq!(item.range, TextRange::new(7.into(), 7.into())); + } + + assert!(actual_items + .iter() + .any(|item| item.data.label() == "theindex")); + } + + #[test] + fn test_simple_existing() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\begin{d}")]) + .main("main.tex") + .line(0) + .character(7) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_component_environments(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(7.into(), 8.into())); + } + } +} diff --git a/support/texlab/src/features/completion/entry_type.rs b/support/texlab/src/features/completion/entry_type.rs new file mode 100644 index 0000000000..c8774ea3ff --- /dev/null +++ b/support/texlab/src/features/completion/entry_type.rs @@ -0,0 +1,231 @@ +use cancellation::CancellationToken; +use cstree::{TextRange, TextSize}; +use lsp_types::CompletionParams; + +use crate::{features::cursor::CursorContext, LANGUAGE_DATA}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_entry_types<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + if cancellation_token.is_canceled() { + return None; + } + + let range = context + .cursor + .as_bibtex() + .filter(|token| token.kind().is_type()) + .filter(|token| token.text_range().start() != context.offset) + .map(|token| token.text_range()) + .map(|range| TextRange::new(range.start() + TextSize::from(1), range.end()))?; + + for ty in &LANGUAGE_DATA.entry_types { + let data = InternalCompletionItemData::EntryType { ty }; + let item = InternalCompletionItem::new(range, data); + items.push(item); + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_entry_types(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_entry_types(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_at_empty() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@")]) + .main("main.bib") + .line(0) + .character(1) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_entry_types(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(1.into(), 1.into())); + } + } + + #[test] + fn test_before_preamble() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@preamble")]) + .main("main.bib") + .line(0) + .character(1) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_entry_types(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(1.into(), 9.into())); + } + } + + #[test] + fn test_before_string() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@string")]) + .main("main.bib") + .line(0) + .character(1) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_entry_types(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(1.into(), 7.into())); + } + } + + #[test] + fn test_before_article() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@article")]) + .main("main.bib") + .line(0) + .character(1) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_entry_types(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(1.into(), 8.into())); + } + } + + #[test] + fn test_after_preamble() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@preamble{")]) + .main("main.bib") + .line(0) + .character(9) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_entry_types(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(1.into(), 9.into())); + } + } + + #[test] + fn test_after_string() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@string{")]) + .main("main.bib") + .line(0) + .character(7) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_entry_types(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(1.into(), 7.into())); + } + } + + #[test] + fn test_complete_entry() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@article{foo, author = {foo}}")]) + .main("main.bib") + .line(0) + .character(3) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_entry_types(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(1.into(), 8.into())); + } + } + + #[test] + fn test_complete_entry_field() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@article{foo, author = {foo}}")]) + .main("main.bib") + .line(0) + .character(17) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_entry_types(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } +} diff --git a/support/texlab/src/features/completion/field.rs b/support/texlab/src/features/completion/field.rs new file mode 100644 index 0000000000..d7297b481b --- /dev/null +++ b/support/texlab/src/features/completion/field.rs @@ -0,0 +1,200 @@ +use cancellation::CancellationToken; +use cstree::TextRange; +use lsp_types::CompletionParams; + +use crate::{ + features::cursor::CursorContext, + syntax::{bibtex, CstNode}, + LANGUAGE_DATA, +}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_fields<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + cancellation_token.result().ok()?; + + let token = context.cursor.as_bibtex()?; + let range = if token.kind() == bibtex::WORD { + token.text_range() + } else { + TextRange::empty(context.offset) + }; + + let parent = token.parent(); + if let Some(entry) = bibtex::Entry::cast(parent) { + if entry.key()?.text_range() == token.text_range() { + return None; + } + } else { + bibtex::Field::cast(parent)?; + } + + for field in &LANGUAGE_DATA.fields { + let data = InternalCompletionItemData::Field { field }; + let item = InternalCompletionItem::new(range, data); + items.push(item); + } + Some(()) +} + +#[cfg(test)] +mod tests { + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_fields(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_fields(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_entry_open() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@article{foo,")]) + .main("main.bib") + .line(0) + .character(13) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_fields(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(13.into(), 13.into())); + } + } + + #[test] + fn test_empty_entry_closed() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@article{foo,}")]) + .main("main.bib") + .line(0) + .character(13) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_fields(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(13.into(), 13.into())); + } + } + + #[test] + fn test_entry_field_name() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@article{foo, a")]) + .main("main.bib") + .line(0) + .character(15) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_fields(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(14.into(), 15.into())); + } + } + + #[test] + fn test_entry_field_value() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@article{foo, author = bar}")]) + .main("main.bib") + .line(0) + .character(24) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_fields(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_entry_two_fields_name_closed() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@article{foo, author = bar, baz}")]) + .main("main.bib") + .line(0) + .character(29) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_fields(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(28.into(), 31.into())); + } + } + + #[test] + fn test_entry_two_fields_name_open() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@article{foo, author = bar, baz")]) + .main("main.bib") + .line(0) + .character(29) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_fields(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(28.into(), 31.into())); + } + } +} diff --git a/support/texlab/src/features/completion/glossary_ref.rs b/support/texlab/src/features/completion/glossary_ref.rs new file mode 100644 index 0000000000..4529129fd7 --- /dev/null +++ b/support/texlab/src/features/completion/glossary_ref.rs @@ -0,0 +1,141 @@ +use cancellation::CancellationToken; +use cstree::TextRange; +use lsp_types::CompletionParams; + +use crate::{ + features::cursor::CursorContext, + syntax::{latex, CstNode}, +}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_glossary_entries<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + cancellation_token.result().ok()?; + + let token = context.cursor.as_latex()?; + let group = latex::CurlyGroupWord::cast(token.parent()) + .filter(|group| context.is_inside_latex_curly(group))?; + latex::GlossaryEntryReference::cast(group.syntax().parent()?)?; + let range = if token.kind() == latex::WORD { + token.text_range() + } else { + TextRange::empty(context.offset) + }; + + for document in &context.request.subset.documents { + if let Some(data) = document.data.as_latex() { + for node in data.root.descendants() { + cancellation_token.result().ok()?; + + if let Some(name) = latex::GlossaryEntryDefinition::cast(node) + .and_then(|entry| entry.name()) + .and_then(|name| name.word()) + .map(|name| name.text()) + { + items.push(InternalCompletionItem::new( + range, + InternalCompletionItemData::GlossaryEntry { name }, + )); + } else if let Some(name) = latex::AcronymDefinition::cast(node) + .and_then(|entry| entry.name()) + .and_then(|name| name.word()) + .map(|name| name.text()) + { + items.push(InternalCompletionItem::new( + range, + InternalCompletionItemData::Acronym { name }, + )); + } + } + } + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use cstree::TextRange; + + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_glossary_entries(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_glossary_entries(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_simple() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\newacronym[longplural={Frames per Second}]{fpsLabel}{FPS}{Frame per Second}\n\\gls{f}")]) + .main("main.tex") + .line(1) + .character(6) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_glossary_entries(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(82.into(), 83.into())); + } + } + + #[test] + fn test_open_brace() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\newacronym[longplural={Frames per Second}]{fpsLabel}{FPS}{Frame per Second}\n\\gls{f")]) + .main("main.tex") + .line(1) + .character(6) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_glossary_entries(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(82.into(), 83.into())); + } + } +} diff --git a/support/texlab/src/features/completion/import.rs b/support/texlab/src/features/completion/import.rs new file mode 100644 index 0000000000..ac5f2e6e40 --- /dev/null +++ b/support/texlab/src/features/completion/import.rs @@ -0,0 +1,196 @@ +use cancellation::CancellationToken; +use cstree::TextRange; +use lsp_types::CompletionParams; +use rustc_hash::FxHashSet; +use smol_str::SmolStr; + +use crate::{ + component_db::COMPONENT_DATABASE, + features::cursor::CursorContext, + syntax::{latex, CstNode}, +}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_imports<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + let token = context.cursor.as_latex()?; + let range = if token.kind() == latex::WORD { + token.text_range() + } else { + TextRange::empty(context.offset) + }; + + let group = latex::CurlyGroupWordList::cast(token.parent()) + .filter(|group| context.is_inside_latex_curly(group))?; + + let (extension, mut factory): ( + &str, + Box<dyn FnMut(SmolStr) -> InternalCompletionItemData<'a>>, + ) = match group.syntax().parent()?.kind() { + latex::PACKAGE_INCLUDE => ( + "sty", + Box::new(|name| InternalCompletionItemData::Package { name }), + ), + latex::CLASS_INCLUDE => ( + "cls", + Box::new(|name| InternalCompletionItemData::Class { name }), + ), + _ => return None, + }; + + let mut file_names = FxHashSet::default(); + for file_name in COMPONENT_DATABASE + .components + .iter() + .flat_map(|comp| comp.file_names.iter()) + .filter(|file_name| file_name.ends_with(extension)) + { + cancellation_token.result().ok()?; + file_names.insert(file_name); + let stem = &file_name[0..file_name.len() - 4]; + let data = factory(stem.into()); + let item = InternalCompletionItem::new(range, data); + items.push(item); + } + + let resolver = context.request.context.resolver.lock().unwrap(); + for file_name in resolver + .files_by_name + .keys() + .filter(|file_name| file_name.ends_with(extension) && !file_names.contains(file_name)) + { + cancellation_token.result().ok()?; + + let stem = &file_name[0..file_name.len() - 4]; + let data = factory(stem.into()); + let item = InternalCompletionItem::new(range, data); + items.push(item); + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_imports(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_imports(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_latex_simple_package() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\usepackage{}")]) + .main("main.tex") + .line(0) + .character(12) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_imports(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(12.into(), 12.into())); + } + } + + #[test] + fn test_latex_open_brace_package() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\usepackage{ \\foo")]) + .main("main.tex") + .line(0) + .character(12) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_imports(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(12.into(), 12.into())); + } + } + + #[test] + fn test_latex_simple_class() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\documentclass{}")]) + .main("main.tex") + .line(0) + .character(15) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_imports(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(15.into(), 15.into())); + } + } + + #[test] + fn test_latex_open_brace_class() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\documentclass{ \\foo")]) + .main("main.tex") + .line(0) + .character(15) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_imports(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(15.into(), 15.into())); + } + } +} diff --git a/support/texlab/src/features/completion/include.rs b/support/texlab/src/features/completion/include.rs new file mode 100644 index 0000000000..c711ea5ce7 --- /dev/null +++ b/support/texlab/src/features/completion/include.rs @@ -0,0 +1,181 @@ +use std::{ + convert::TryFrom, + fs, + path::{Path, PathBuf}, +}; + +use cancellation::CancellationToken; +use cstree::{TextRange, TextSize}; +use lsp_types::CompletionParams; + +use crate::{ + features::cursor::CursorContext, + syntax::{latex, CstNode}, +}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_includes<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + cancellation_token.result().ok()?; + if context.request.main_document().uri.scheme() != "file" { + return None; + } + + let token = context.cursor.as_latex()?; + let (path_text, path_range) = if token.kind() == latex::WORD { + (token.text(), token.text_range()) + } else { + ("", TextRange::empty(context.offset)) + }; + + let group = latex::CurlyGroupWordList::cast(token.parent()) + .filter(|group| context.is_inside_latex_curly(group))?; + let include = group.syntax().parent()?; + let (include_extension, extensions): (bool, &[&str]) = match include.kind() { + latex::PACKAGE_INCLUDE => (false, &["sty"]), + latex::CLASS_INCLUDE => (false, &["cls"]), + latex::LATEX_INCLUDE => { + let include = latex::Include::cast(include)?; + ( + matches!(include.command()?.text(), "\\input" | "\\subfile"), + &["tex"], + ) + } + latex::BIBLATEX_INCLUDE => (true, &["bib"]), + latex::BIBTEX_INCLUDE => (false, &["bib"]), + latex::GRAPHICS_INCLUDE => (true, &["pdf", "png", "jpg", "jpeg", "bmp"]), + latex::SVG_INCLUDE => (true, &["svg"]), + latex::INKSCAPE_INCLUDE => (true, &["pdf", "eps", "ps", "png"]), + latex::VERBATIM_INCLUDE => (true, &[]), + _ => return None, + }; + + let segment_range = if path_text.is_empty() { + path_range + } else { + let start = + path_range.end() - TextSize::try_from(path_text.split('/').last()?.len()).ok()?; + TextRange::new(start, path_range.end()) + }; + + let current_dir = current_dir(context, path_text)?; + for entry in fs::read_dir(current_dir).ok()?.filter_map(Result::ok) { + let mut path = entry.path(); + + let file_type = entry.file_type().ok()?; + if file_type.is_file() && is_included(&path, extensions) { + if !include_extension { + remove_extension(&mut path); + } + let name = path.file_name()?.to_str()?.into(); + let data = InternalCompletionItemData::File { name }; + let item = InternalCompletionItem::new(segment_range, data); + items.push(item); + } else if file_type.is_dir() { + let name = path.file_name()?.to_str()?.into(); + let data = InternalCompletionItemData::Directory { name }; + let item = InternalCompletionItem::new(segment_range, data); + items.push(item); + } + } + + Some(()) +} + +fn current_dir(context: &CursorContext<CompletionParams>, path_text: &str) -> Option<PathBuf> { + let mut path = context + .request + .context + .options + .read() + .unwrap() + .root_directory + .as_ref() + .map(|root_directory| { + context + .request + .context + .current_directory + .join(root_directory) + }) + .unwrap_or_else(|| { + let mut path = context.request.main_document().uri.to_file_path().unwrap(); + path.pop(); + path + }); + + path = PathBuf::from(path.to_str()?.replace('\\', "/")); + if !path_text.is_empty() { + path.push(&path_text); + if !path_text.ends_with('/') { + path.pop(); + } + } + Some(path) +} + +fn is_included(file: &Path, allowed_extensions: &[&str]) -> bool { + allowed_extensions.is_empty() + || file + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_lowercase()) + .map(|ext| allowed_extensions.contains(&ext.as_str())) + .unwrap_or_default() +} + +fn remove_extension(path: &mut PathBuf) { + if let Some(stem) = path + .file_stem() + .and_then(|stem| stem.to_str()) + .map(ToOwned::to_owned) + { + path.pop(); + path.push(stem); + } +} + +#[cfg(test)] +mod tests { + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_includes(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_includes(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } +} diff --git a/support/texlab/src/features/completion/label.rs b/support/texlab/src/features/completion/label.rs new file mode 100644 index 0000000000..2f7e2d8f77 --- /dev/null +++ b/support/texlab/src/features/completion/label.rs @@ -0,0 +1,196 @@ +use cancellation::CancellationToken; +use cstree::TextRange; +use lsp_types::CompletionParams; + +use crate::{ + features::{cursor::CursorContext, lsp_kinds::Structure}, + render_label, + syntax::{latex, CstNode}, + LabelledObject, +}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_labels<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + cancellation_token.result().ok()?; + + let token = context.cursor.as_latex()?; + let is_math = latex::CurlyGroupWordList::cast(token.parent()) + .filter(|group| context.is_inside_latex_curly(group)) + .and_then(|group| group.syntax().parent()) + .and_then(|reference| latex::LabelReference::cast(reference)) + .and_then(|refernce| refernce.command()) + .map(|reference| reference.text() == "\\eqref") + .or_else(|| { + latex::CurlyGroupWord::cast(token.parent()) + .filter(|group| context.is_inside_latex_curly(group)) + .and_then(|group| group.syntax().parent()) + .and_then(|reference| latex::LabelReferenceRange::cast(reference)) + .map(|_| false) + })?; + + let range = if token.kind() == latex::WORD { + token.text_range() + } else { + TextRange::empty(context.offset) + }; + + for document in &context.request.subset.documents { + if let Some(data) = document.data.as_latex() { + for label in data + .root + .descendants() + .filter(|_| !cancellation_token.is_canceled()) + .filter_map(latex::LabelDefinition::cast) + { + if let Some(name) = label + .name() + .and_then(|name| name.word()) + .map(|name| name.text()) + { + match render_label(&context.request.subset, name, Some(label)) { + Some(rendered_label) => { + let kind = match &rendered_label.object { + LabelledObject::Section { .. } => Structure::Section, + LabelledObject::Float { .. } => Structure::Float, + LabelledObject::Theorem { .. } => Structure::Theorem, + LabelledObject::Equation => Structure::Equation, + LabelledObject::EnumItem => Structure::Item, + }; + + if is_math && kind != Structure::Equation { + continue; + } + + let header = rendered_label.detail(); + let footer = match &rendered_label.object { + LabelledObject::Float { caption, .. } => Some(caption.clone()), + _ => None, + }; + + let text = format!("{} {}", name, rendered_label.reference()); + + let item = InternalCompletionItem::new( + range, + InternalCompletionItemData::Label { + name, + kind, + header, + footer, + text, + }, + ); + items.push(item); + } + None => { + let kind = Structure::Label; + let header = None; + let footer = None; + let text = name.to_string(); + let item = InternalCompletionItem::new( + range, + InternalCompletionItemData::Label { + name, + kind, + header, + footer, + text, + }, + ); + items.push(item); + } + } + } + } + } + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use cstree::TextRange; + + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_labels(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_labels(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_simple() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\ref{}\\label{foo}")]) + .main("main.tex") + .line(0) + .character(5) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_labels(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(5.into(), 5.into())); + } + } + + #[test] + fn test_simple_range() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\crefrange{\n\\label{foo}")]) + .main("main.tex") + .line(0) + .character(11) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_labels(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(11.into(), 11.into())); + } + } +} diff --git a/support/texlab/src/features/completion/theorem.rs b/support/texlab/src/features/completion/theorem.rs new file mode 100644 index 0000000000..54db031a4b --- /dev/null +++ b/support/texlab/src/features/completion/theorem.rs @@ -0,0 +1,152 @@ +use cancellation::CancellationToken; +use cstree::TextRange; +use lsp_types::CompletionParams; + +use crate::{ + features::cursor::CursorContext, + syntax::{latex, CstNode}, +}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_theorem_environments<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + token: &CancellationToken, +) -> Option<()> { + if token.is_canceled() { + return None; + } + + let token = context.cursor.as_latex()?; + let group = latex::CurlyGroupWord::cast(token.parent()) + .filter(|group| context.is_inside_latex_curly(group))?; + if !matches!(group.syntax().parent()?.kind(), latex::BEGIN | latex::END) { + return None; + } + + let range = if token.kind() == latex::WORD { + token.text_range() + } else { + TextRange::empty(context.offset) + }; + + for document in &context.request.subset.documents { + if let Some(data) = document.data.as_latex() { + for environment in &data.extras.theorem_environments { + items.push(InternalCompletionItem::new( + range, + InternalCompletionItemData::UserEnvironment { + name: &environment.name, + }, + )); + } + } + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_theorem_environments(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_theorem_environments(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_simple() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\begin{ \\newtheorem{lemma}{Lemma}")]) + .main("main.tex") + .line(0) + .character(7) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_theorem_environments(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(7.into(), 7.into())); + } + } + + #[test] + fn test_simple_end() { + let request = FeatureTester::builder() + .files(vec![( + "main.tex", + "\\newtheorem{lemma}{Lemma}\\begin{a}\n\\end{", + )]) + .main("main.tex") + .line(1) + .character(5) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_theorem_environments(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(40.into(), 40.into())); + } + } + + #[test] + fn test_simple_existing() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\begin{d}\\newtheorem{lemma}{Lemma}")]) + .main("main.tex") + .line(0) + .character(7) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_theorem_environments(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(7.into(), 8.into())); + } + } +} diff --git a/support/texlab/src/features/completion/tikz_library.rs b/support/texlab/src/features/completion/tikz_library.rs new file mode 100644 index 0000000000..da2a26cc9b --- /dev/null +++ b/support/texlab/src/features/completion/tikz_library.rs @@ -0,0 +1,169 @@ +use cancellation::CancellationToken; +use cstree::TextRange; +use lsp_types::CompletionParams; + +use crate::{ + features::cursor::CursorContext, + syntax::{latex, CstNode}, + LANGUAGE_DATA, +}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_tikz_libraries<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + cancellation_token.result().ok()?; + + let token = context.cursor.as_latex()?; + let range = if token.kind() == latex::WORD { + token.text_range() + } else { + TextRange::empty(context.offset) + }; + + let group = latex::CurlyGroupWordList::cast(token.parent()) + .filter(|group| context.is_inside_latex_curly(group))?; + let import = latex::TikzLibraryImport::cast(group.syntax().parent()?)?; + + if import.command()?.text() == "\\usepgflibrary" { + for name in &LANGUAGE_DATA.pgf_libraries { + items.push(InternalCompletionItem::new( + range, + InternalCompletionItemData::PgfLibrary { name }, + )); + } + } else { + for name in &LANGUAGE_DATA.tikz_libraries { + items.push(InternalCompletionItem::new( + range, + InternalCompletionItemData::TikzLibrary { name }, + )); + } + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_tikz_libraries(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_tikz_libraries(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_latex_simple_pgf() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\usepgflibrary{}")]) + .main("main.tex") + .line(0) + .character(15) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_tikz_libraries(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(15.into(), 15.into())); + } + } + + #[test] + fn test_latex_open_brace_pgf() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\usepgflibrary{")]) + .main("main.tex") + .line(0) + .character(15) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_tikz_libraries(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(15.into(), 15.into())); + } + } + + #[test] + fn test_latex_simple_tikz() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\usetikzlibrary{}")]) + .main("main.tex") + .line(0) + .character(16) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_tikz_libraries(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(16.into(), 16.into())); + } + } + + #[test] + fn test_latex_open_brace_tikz() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\usetikzlibrary{")]) + .main("main.tex") + .line(0) + .character(16) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_tikz_libraries(&context, &mut actual_items, CancellationToken::none()); + + assert!(!actual_items.is_empty()); + for item in actual_items { + assert_eq!(item.range, TextRange::new(16.into(), 16.into())); + } + } +} diff --git a/support/texlab/src/features/completion/types.rs b/support/texlab/src/features/completion/types.rs new file mode 100644 index 0000000000..b58a155fc4 --- /dev/null +++ b/support/texlab/src/features/completion/types.rs @@ -0,0 +1,150 @@ +use std::sync::Arc; + +use cstree::TextRange; +use serde::{Deserialize, Serialize}; +use smol_str::SmolStr; + +use crate::{features::lsp_kinds::Structure, BibtexEntryTypeDoc, BibtexFieldDoc, Uri}; + +#[derive(Debug, Clone)] +pub struct InternalCompletionItem<'a> { + pub range: TextRange, + pub data: InternalCompletionItemData<'a>, + pub preselect: bool, + pub score: Option<i64>, +} + +impl<'a> InternalCompletionItem<'a> { + pub fn new(range: TextRange, data: InternalCompletionItemData<'a>) -> Self { + Self { + range, + data, + preselect: false, + score: None, + } + } +} + +#[derive(Debug, Clone)] +pub enum InternalCompletionItemData<'a> { + EntryType { + ty: &'a BibtexEntryTypeDoc, + }, + Field { + field: &'a BibtexFieldDoc, + }, + Argument { + name: &'a str, + image: Option<&'a str>, + }, + BeginCommand, + Citation { + uri: Arc<Uri>, + key: &'a str, + text: String, + ty: Structure, + }, + ComponentCommand { + name: &'a SmolStr, + image: Option<&'a str>, + glyph: Option<&'a str>, + file_names: &'a [SmolStr], + }, + ComponentEnvironment { + name: &'a SmolStr, + file_names: &'a [SmolStr], + }, + Class { + name: SmolStr, + }, + Package { + name: SmolStr, + }, + Color { + name: &'a str, + }, + ColorModel { + name: &'a str, + }, + Acronym { + name: &'a str, + }, + GlossaryEntry { + name: &'a str, + }, + File { + name: SmolStr, + }, + Directory { + name: SmolStr, + }, + Label { + name: &'a str, + kind: Structure, + header: Option<String>, + footer: Option<String>, + text: String, + }, + UserCommand { + name: &'a str, + }, + UserEnvironment { + name: &'a str, + }, + PgfLibrary { + name: &'a str, + }, + TikzLibrary { + name: &'a str, + }, +} + +impl<'a> InternalCompletionItemData<'a> { + pub fn label<'b: 'a>(&'b self) -> &'a str { + match self { + Self::EntryType { ty } => &ty.name, + Self::Field { field } => &field.name, + Self::Argument { name, .. } => name, + Self::BeginCommand => "begin", + Self::Citation { key, .. } => &key, + Self::ComponentCommand { name, .. } => name, + Self::ComponentEnvironment { name, .. } => name, + Self::Class { name } => name, + Self::Package { name } => name, + Self::Color { name } => name, + Self::ColorModel { name } => name, + Self::Acronym { name } => name, + Self::GlossaryEntry { name } => name, + Self::File { name } => name, + Self::Directory { name } => name, + Self::Label { name, .. } => name, + Self::UserCommand { name } => name, + Self::UserEnvironment { name } => name, + Self::PgfLibrary { name } => name, + Self::TikzLibrary { name } => name, + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CompletionItemData { + Command, + CommandSnippet, + Environment, + Label, + Folder, + File, + PgfLibrary, + TikzLibrary, + Color, + ColorModel, + Package, + Class, + EntryType, + FieldName, + Citation { uri: Uri, key: SmolStr }, + Argument, + Acronym, + GlossaryEntry, +} diff --git a/support/texlab/src/features/completion/user_command.rs b/support/texlab/src/features/completion/user_command.rs new file mode 100644 index 0000000000..8b4473f31c --- /dev/null +++ b/support/texlab/src/features/completion/user_command.rs @@ -0,0 +1,100 @@ +use cancellation::CancellationToken; +use lsp_types::CompletionParams; + +use crate::features::cursor::CursorContext; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_user_commands<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + cancellation_token.result().ok()?; + + let range = context.cursor.command_range(context.offset)?; + let token = context.cursor.as_latex()?; + + for document in &context.request.subset.documents { + if let Some(data) = document.data.as_latex() { + for name in data + .extras + .command_names + .iter() + .filter(|name| name.as_str() != token.text()) + .map(|name| &name[1..]) + { + cancellation_token.result().ok()?; + items.push(InternalCompletionItem::new( + range, + InternalCompletionItemData::UserCommand { name }, + )); + } + } + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use cstree::TextRange; + + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_user_commands(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_user_commands(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_latex_simple() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "\\foo\\bar")]) + .main("main.tex") + .line(0) + .character(4) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_user_commands(&context, &mut actual_items, CancellationToken::none()); + + assert_eq!(actual_items.len(), 1); + for item in actual_items { + assert_eq!(item.range, TextRange::new(1.into(), 4.into())); + } + } +} diff --git a/support/texlab/src/features/completion/user_environment.rs b/support/texlab/src/features/completion/user_environment.rs new file mode 100644 index 0000000000..a1a5701fac --- /dev/null +++ b/support/texlab/src/features/completion/user_environment.rs @@ -0,0 +1,116 @@ +use cancellation::CancellationToken; +use cstree::TextRange; +use lsp_types::CompletionParams; + +use crate::{ + features::cursor::CursorContext, + syntax::{latex, CstNode}, +}; + +use super::types::{InternalCompletionItem, InternalCompletionItemData}; + +pub fn complete_user_environments<'a>( + context: &'a CursorContext<CompletionParams>, + items: &mut Vec<InternalCompletionItem<'a>>, + cancellation_token: &CancellationToken, +) -> Option<()> { + cancellation_token.result().ok()?; + + let token = context.cursor.as_latex()?; + let group = latex::CurlyGroupWord::cast(token.parent()) + .filter(|group| context.is_inside_latex_curly(group))?; + if !matches!(group.syntax().parent()?.kind(), latex::BEGIN | latex::END) { + return None; + } + + let range = if token.kind() == latex::WORD { + token.text_range() + } else { + TextRange::empty(context.offset) + }; + + for document in &context.request.subset.documents { + if let Some(data) = document.data.as_latex() { + for name in data + .extras + .environment_names + .iter() + .filter(|name| name.as_str() != token.text()) + { + cancellation_token.result().ok()?; + items.push(InternalCompletionItem::new( + range, + InternalCompletionItemData::UserEnvironment { name }, + )); + } + } + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use cstree::TextRange; + + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_user_environments(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_user_environments(&context, &mut actual_items, CancellationToken::none()); + + assert!(actual_items.is_empty()); + } + + #[test] + fn test_latex_simple() { + let request = FeatureTester::builder() + .files(vec![( + "main.tex", + "\\begin{foo}\\end{foo} \\begin{bar}\\end{bar}", + )]) + .main("main.tex") + .line(0) + .character(7) + .build() + .completion(); + + let context = CursorContext::new(request); + let mut actual_items = Vec::new(); + complete_user_environments(&context, &mut actual_items, CancellationToken::none()); + + assert_eq!(actual_items.len(), 1); + for item in actual_items { + assert_eq!(item.range, TextRange::new(7.into(), 10.into())); + } + } +} diff --git a/support/texlab/src/features/completion/util.rs b/support/texlab/src/features/completion/util.rs new file mode 100644 index 0000000000..296d6d25d9 --- /dev/null +++ b/support/texlab/src/features/completion/util.rs @@ -0,0 +1,66 @@ +use lsp_types::{CompletionItemKind, CompletionParams, Documentation, MarkupContent, MarkupKind}; +use smol_str::SmolStr; + +use crate::features::FeatureRequest; + +pub fn component_detail(file_names: &[SmolStr]) -> String { + if file_names.is_empty() { + "built-in".to_owned() + } else { + file_names.join(", ") + } +} + +pub fn image_documentation( + request: &FeatureRequest<CompletionParams>, + name: &str, + image: &str, +) -> Option<Documentation> { + if supports_images(request) { + Some(Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value: format!( + "![{}](data:image/png;base64,{}|width=48,height=48)", + name, image + ), + })) + } else { + None + } +} + +fn supports_images(request: &FeatureRequest<CompletionParams>) -> bool { + request + .context + .client_capabilities + .lock() + .unwrap() + .text_document + .as_ref() + .and_then(|cap| cap.completion.as_ref()) + .and_then(|cap| cap.completion_item.as_ref()) + .and_then(|cap| cap.documentation_format.as_ref()) + .map_or(true, |formats| formats.contains(&MarkupKind::Markdown)) +} + +pub fn adjust_kind( + request: &FeatureRequest<CompletionParams>, + kind: CompletionItemKind, +) -> CompletionItemKind { + if let Some(value_set) = request + .context + .client_capabilities + .lock() + .unwrap() + .text_document + .as_ref() + .and_then(|cap| cap.completion.as_ref()) + .and_then(|cap| cap.completion_item_kind.as_ref()) + .and_then(|cap| cap.value_set.as_ref()) + { + if value_set.contains(&kind) { + return kind; + } + } + CompletionItemKind::Text +} diff --git a/support/texlab/src/features/cursor.rs b/support/texlab/src/features/cursor.rs new file mode 100644 index 0000000000..19079b841b --- /dev/null +++ b/support/texlab/src/features/cursor.rs @@ -0,0 +1,252 @@ +use cstree::{TextRange, TextSize}; +use lsp_types::{ + CompletionParams, GotoDefinitionParams, HoverParams, Position, ReferenceParams, RenameParams, + TextDocumentPositionParams, +}; + +use crate::{ + syntax::{bibtex, latex, CstNode}, + DocumentData, LineIndexExt, +}; + +use super::FeatureRequest; + +#[derive(Debug)] +pub enum Cursor { + Latex(latex::SyntaxToken), + Bibtex(bibtex::SyntaxToken), + Nothing, +} + +impl Cursor { + pub fn new_latex( + left: Option<latex::SyntaxToken>, + right: Option<latex::SyntaxToken>, + ) -> Option<Self> { + let left = left?; + let right = right?; + + if left.kind().is_command_name() { + return Some(Self::Latex(left)); + } + + if right.kind() == latex::WORD { + return Some(Self::Latex(right)); + } + + if left.kind() == latex::WORD { + return Some(Self::Latex(left)); + } + + if right.kind().is_command_name() { + return Some(Self::Latex(right)); + } + + Some(Self::Latex(right)) + } + + pub fn new_bibtex( + left: Option<bibtex::SyntaxToken>, + right: Option<bibtex::SyntaxToken>, + ) -> Option<Self> { + let left = left?; + let right = right?; + + if right.kind().is_type() { + return Some(Self::Bibtex(right)); + } + + if left.kind().is_type() { + return Some(Self::Bibtex(left)); + } + + if left.kind() == bibtex::COMMAND_NAME { + return Some(Self::Bibtex(left)); + } + + if right.kind() == bibtex::WORD { + return Some(Self::Bibtex(right)); + } + + if left.kind() == bibtex::WORD { + return Some(Self::Bibtex(left)); + } + + if right.kind() == bibtex::COMMAND_NAME { + return Some(Self::Bibtex(right)); + } + + Some(Self::Bibtex(right)) + } + + pub fn as_latex(&self) -> Option<&latex::SyntaxToken> { + if let Self::Latex(v) = self { + Some(v) + } else { + None + } + } + + pub fn as_bibtex(&self) -> Option<&bibtex::SyntaxToken> { + if let Self::Bibtex(v) = self { + Some(v) + } else { + None + } + } + + pub fn command_range(&self, offset: TextSize) -> Option<TextRange> { + self.as_latex() + .filter(|token| token.kind().is_command_name()) + .filter(|token| token.text_range().start() != offset) + .map(|token| token.text_range()) + .map(|range| TextRange::new(range.start() + TextSize::from(1), range.end())) + .or_else(|| { + self.as_bibtex() + .filter(|token| token.kind() == bibtex::COMMAND_NAME) + .filter(|token| token.text_range().start() != offset) + .map(|token| token.text_range()) + .map(|range| TextRange::new(range.start() + TextSize::from(1), range.end())) + }) + } +} + +pub struct CursorContext<P> { + pub request: FeatureRequest<P>, + pub cursor: Cursor, + pub offset: TextSize, +} + +impl<P: HasPosition> CursorContext<P> { + pub fn new(request: FeatureRequest<P>) -> Self { + let main_document = request.main_document(); + let offset = main_document + .line_index + .offset_lsp(request.params.position()); + + let cursor = match &main_document.data { + DocumentData::Latex(data) => { + let left = data.root.token_at_offset(offset).left_biased(); + let right = data.root.token_at_offset(offset).right_biased(); + Cursor::new_latex(left, right) + } + DocumentData::Bibtex(data) => { + let left = data.root.token_at_offset(offset).left_biased(); + let right = data.root.token_at_offset(offset).right_biased(); + Cursor::new_bibtex(left, right) + } + DocumentData::BuildLog(_) => None, + }; + + Self { + request, + cursor: cursor.unwrap_or(Cursor::Nothing), + offset, + } + } + + pub fn is_inside_latex_curly<'a>(&self, group: &impl latex::HasCurly<'a>) -> bool { + group.small_range().contains(self.offset) || group.right_curly().is_none() + } + + pub fn find_citation_key_word(&self) -> Option<(&str, TextRange)> { + let key = self + .cursor + .as_latex() + .filter(|token| token.kind() == latex::WORD)?; + + let group = latex::CurlyGroupWordList::cast(key.parent())?; + latex::Citation::cast(group.syntax().parent()?)?; + Some((key.text(), key.text_range())) + } + + pub fn find_citation_key_command(&self) -> Option<(&str, TextRange)> { + let command = self.cursor.as_latex()?; + + let citation = latex::Citation::cast(command.parent())?; + let key = citation.key_list()?.words().next()?; + Some((key.text(), key.text_range())) + } + + pub fn find_entry_key(&self) -> Option<(&str, TextRange)> { + let key = self + .cursor + .as_bibtex() + .filter(|token| token.kind() == bibtex::WORD)?; + + bibtex::Entry::cast(key.parent())?; + Some((key.text(), key.text_range())) + } + + pub fn find_label_name_word(&self) -> Option<(&str, TextRange)> { + let name = self + .cursor + .as_latex() + .filter(|token| token.kind() == latex::WORD)?; + + if matches!( + name.parent().parent()?.kind(), + latex::LABEL_DEFINITION | latex::LABEL_REFERENCE | latex::LABEL_REFERENCE_RANGE + ) { + Some((name.text(), name.text_range())) + } else { + None + } + } + + pub fn find_label_name_command(&self) -> Option<(&str, TextRange)> { + let node = self.cursor.as_latex()?.parent(); + if let Some(label) = latex::LabelDefinition::cast(node) { + let name = label.name()?.word()?; + Some((name.text(), name.text_range())) + } else if let Some(label) = latex::LabelReference::cast(node) { + let name = label.name_list()?.words().next()?; + Some((name.text(), name.text_range())) + } else if let Some(label) = latex::LabelReferenceRange::cast(node) { + let name = label.from()?.word()?; + Some((name.text(), name.text_range())) + } else { + None + } + } +} + +pub trait HasPosition { + fn position(&self) -> Position; +} + +impl HasPosition for CompletionParams { + fn position(&self) -> Position { + self.text_document_position.position + } +} + +impl HasPosition for TextDocumentPositionParams { + fn position(&self) -> Position { + self.position + } +} + +impl HasPosition for RenameParams { + fn position(&self) -> Position { + self.text_document_position.position + } +} + +impl HasPosition for ReferenceParams { + fn position(&self) -> Position { + self.text_document_position.position + } +} + +impl HasPosition for HoverParams { + fn position(&self) -> Position { + self.text_document_position_params.position + } +} + +impl HasPosition for GotoDefinitionParams { + fn position(&self) -> Position { + self.text_document_position_params.position + } +} diff --git a/support/texlab/src/features/definition.rs b/support/texlab/src/features/definition.rs new file mode 100644 index 0000000000..c49f172399 --- /dev/null +++ b/support/texlab/src/features/definition.rs @@ -0,0 +1,29 @@ +mod command; +mod document; +mod entry; +mod label; +mod string; + +use cancellation::CancellationToken; +use lsp_types::{GotoDefinitionParams, GotoDefinitionResponse}; + +use self::{ + command::goto_command_definition, document::goto_document_definition, + entry::goto_entry_definition, label::goto_label_definition, string::goto_string_definition, +}; + +use super::{cursor::CursorContext, FeatureRequest}; + +pub fn goto_definition( + request: FeatureRequest<GotoDefinitionParams>, + cancellation_token: &CancellationToken, +) -> Option<GotoDefinitionResponse> { + let context = CursorContext::new(request); + log::debug!("[Definition] Cursor: {:?}", context.cursor); + let links = goto_command_definition(&context, cancellation_token) + .or_else(|| goto_document_definition(&context, cancellation_token)) + .or_else(|| goto_entry_definition(&context, cancellation_token)) + .or_else(|| goto_label_definition(&context, cancellation_token)) + .or_else(|| goto_string_definition(&context, cancellation_token))?; + Some(GotoDefinitionResponse::Link(links)) +} diff --git a/support/texlab/src/features/definition/command.rs b/support/texlab/src/features/definition/command.rs new file mode 100644 index 0000000000..a69796c33f --- /dev/null +++ b/support/texlab/src/features/definition/command.rs @@ -0,0 +1,131 @@ +use cancellation::CancellationToken; +use lsp_types::{GotoDefinitionParams, LocationLink}; + +use crate::{ + features::cursor::CursorContext, + syntax::{latex, CstNode}, + LineIndexExt, +}; + +pub fn goto_command_definition( + context: &CursorContext<GotoDefinitionParams>, + cancellation_token: &CancellationToken, +) -> Option<Vec<LocationLink>> { + let main_document = context.request.main_document(); + + let name = context + .cursor + .as_latex() + .filter(|token| token.kind().is_command_name())?; + + let origin_selection_range = main_document + .line_index + .line_col_lsp_range(name.text_range()); + + for document in &context.request.subset.documents { + if let Some(data) = document.data.as_latex() { + for node in data.root.descendants() { + cancellation_token.result().ok()?; + + if let Some(defintion) = latex::CommandDefinition::cast(node).filter(|def| { + def.name() + .and_then(|name| name.command()) + .map(|name| name.text()) + == Some(name.text()) + }) { + let target_selection_range = document + .line_index + .line_col_lsp_range(defintion.name()?.command()?.text_range()); + + let target_range = document + .line_index + .line_col_lsp_range(defintion.small_range()); + + return Some(vec![LocationLink { + origin_selection_range: Some(origin_selection_range), + target_uri: document.uri.as_ref().clone().into(), + target_range, + target_selection_range, + }]); + } + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + use lsp_types::Range; + + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .definition(); + + let context = CursorContext::new(request); + + let actual_links = goto_command_definition(&context, CancellationToken::none()); + + assert!(actual_links.is_none()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .definition(); + + let context = CursorContext::new(request); + let actual_links = goto_command_definition(&context, CancellationToken::none()); + + assert!(actual_links.is_none()); + } + + #[test] + fn test_command_definition() { + let tester = FeatureTester::builder() + .files(vec![( + "main.tex", + indoc! { + r#" + \DeclareMathOperator{\foo}{foo} + \foo + "# + }, + )]) + .main("main.tex") + .line(1) + .character(2) + .build(); + let target_uri = tester.uri("main.tex").as_ref().clone().into(); + + let request = tester.definition(); + let context = CursorContext::new(request); + let actual_links = goto_command_definition(&context, CancellationToken::none()).unwrap(); + + let expected_links = vec![LocationLink { + origin_selection_range: Some(Range::new_simple(1, 0, 1, 4)), + target_uri, + target_range: Range::new_simple(0, 0, 0, 31), + target_selection_range: Range::new_simple(0, 21, 0, 25), + }]; + + assert_eq!(actual_links, expected_links); + } +} diff --git a/support/texlab/src/features/definition/document.rs b/support/texlab/src/features/definition/document.rs new file mode 100644 index 0000000000..b2b713f260 --- /dev/null +++ b/support/texlab/src/features/definition/document.rs @@ -0,0 +1,112 @@ +use cancellation::CancellationToken; +use lsp_types::{GotoDefinitionParams, LocationLink, Range}; + +use crate::{features::cursor::CursorContext, LineIndexExt, RangeExt}; + +pub fn goto_document_definition( + context: &CursorContext<GotoDefinitionParams>, + cancellation_token: &CancellationToken, +) -> Option<Vec<LocationLink>> { + let main_document = context.request.main_document(); + if let Some(data) = main_document.data.as_latex() { + for include in data + .extras + .explicit_links + .iter() + .filter(|link| link.stem_range.contains_inclusive(context.offset)) + { + for target in &include.targets { + cancellation_token.result().ok()?; + + if context + .request + .subset + .documents + .iter() + .any(|document| document.uri.as_ref() == target.as_ref()) + { + return Some(vec![LocationLink { + origin_selection_range: Some( + main_document + .line_index + .line_col_lsp_range(include.stem_range), + ), + target_uri: target.as_ref().clone().into(), + target_range: Range::new_simple(0, 0, 0, 0), + target_selection_range: Range::new_simple(0, 0, 0, 0), + }]); + } + } + } + } + None +} + +#[cfg(test)] +mod tests { + use lsp_types::Range; + + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .definition(); + + let context = CursorContext::new(request); + let actual_links = goto_document_definition(&context, CancellationToken::none()); + + assert!(actual_links.is_none()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .definition(); + + let context = CursorContext::new(request); + let actual_links = goto_document_definition(&context, CancellationToken::none()); + + assert!(actual_links.is_none()); + } + + #[test] + fn test_simple() { + let tester = FeatureTester::builder() + .files(vec![ + ("foo.tex", r#"\addbibresource{baz.bib}"#), + ("bar.bib", r#"@article{foo, bar = {baz}}"#), + ("baz.bib", r#"@article{foo, bar = {baz}}"#), + ]) + .main("foo.tex") + .line(0) + .character(18) + .build(); + let target_uri = tester.uri("baz.bib").as_ref().clone().into(); + + let request = tester.definition(); + let context = CursorContext::new(request); + let actual_links = goto_document_definition(&context, CancellationToken::none()).unwrap(); + + let expected_links = vec![LocationLink { + origin_selection_range: Some(Range::new_simple(0, 16, 0, 23)), + target_uri, + target_range: Range::new_simple(0, 0, 0, 0), + target_selection_range: Range::new_simple(0, 0, 0, 0), + }]; + + assert_eq!(actual_links, expected_links); + } +} diff --git a/support/texlab/src/features/definition/entry.rs b/support/texlab/src/features/definition/entry.rs new file mode 100644 index 0000000000..7f489ec848 --- /dev/null +++ b/support/texlab/src/features/definition/entry.rs @@ -0,0 +1,125 @@ +use cancellation::CancellationToken; +use lsp_types::{GotoDefinitionParams, LocationLink}; + +use crate::{ + features::cursor::CursorContext, + syntax::{bibtex, latex, CstNode}, + LineIndexExt, +}; + +pub fn goto_entry_definition( + context: &CursorContext<GotoDefinitionParams>, + cancellation_token: &CancellationToken, +) -> Option<Vec<LocationLink>> { + let main_document = context.request.main_document(); + + let key = context + .cursor + .as_latex() + .filter(|token| token.kind() == latex::WORD)?; + + latex::Citation::cast(key.parent().parent()?)?; + + let origin_selection_range = main_document + .line_index + .line_col_lsp_range(key.text_range()); + + for document in &context.request.subset.documents { + if let Some(data) = document.data.as_bibtex() { + for entry in data.root.children().filter_map(bibtex::Entry::cast) { + cancellation_token.result().ok()?; + + if let Some(key) = entry.key().filter(|k| k.text() == key.text()) { + return Some(vec![LocationLink { + origin_selection_range: Some(origin_selection_range), + target_uri: document.uri.as_ref().clone().into(), + target_selection_range: document + .line_index + .line_col_lsp_range(key.text_range()), + target_range: document.line_index.line_col_lsp_range(entry.small_range()), + }]); + } + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + use lsp_types::Range; + + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .definition(); + + let context = CursorContext::new(request); + let actual_links = goto_entry_definition(&context, CancellationToken::none()); + + assert!(actual_links.is_none()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .definition(); + + let context = CursorContext::new(request); + let actual_links = goto_entry_definition(&context, CancellationToken::none()); + + assert!(actual_links.is_none()); + } + + #[test] + fn test_simple() { + let tester = FeatureTester::builder() + .files(vec![ + ( + "foo.tex", + indoc!( + r#" + \addbibresource{baz.bib} + \cite{foo} + "# + ), + ), + ("bar.bib", r#"@article{foo, bar = {baz}}"#), + ("baz.bib", r#"@article{foo, bar = {baz}}"#), + ]) + .main("foo.tex") + .line(1) + .character(6) + .build(); + let target_uri = tester.uri("baz.bib").as_ref().clone().into(); + + let request = tester.definition(); + let context = CursorContext::new(request); + let actual_links = goto_entry_definition(&context, CancellationToken::none()).unwrap(); + + let expected_links = vec![LocationLink { + origin_selection_range: Some(Range::new_simple(1, 6, 1, 9)), + target_uri, + target_range: Range::new_simple(0, 0, 0, 26), + target_selection_range: Range::new_simple(0, 9, 0, 12), + }]; + + assert_eq!(actual_links, expected_links); + } +} diff --git a/support/texlab/src/features/definition/label.rs b/support/texlab/src/features/definition/label.rs new file mode 100644 index 0000000000..65f0981de0 --- /dev/null +++ b/support/texlab/src/features/definition/label.rs @@ -0,0 +1,80 @@ +use cancellation::CancellationToken; +use lsp_types::{GotoDefinitionParams, LocationLink}; + +use crate::{features::cursor::CursorContext, find_label_definition, render_label, LineIndexExt}; + +pub fn goto_label_definition( + context: &CursorContext<GotoDefinitionParams>, + cancellation_token: &CancellationToken, +) -> Option<Vec<LocationLink>> { + let main_document = context.request.main_document(); + + let (name_text, name_range) = context + .find_label_name_word() + .or_else(|| context.find_label_name_command())?; + + let origin_selection_range = main_document.line_index.line_col_lsp_range(name_range); + + for document in &context.request.subset.documents { + cancellation_token.result().ok()?; + if let Some(data) = document.data.as_latex() { + if let Some(definition) = find_label_definition(&data.root, name_text) { + let target_selection_range = definition.name()?.word()?.text_range(); + let target_range = + render_label(&context.request.subset, name_text, Some(definition)) + .map(|label| label.range) + .unwrap_or(target_selection_range); + + return Some(vec![LocationLink { + origin_selection_range: Some(origin_selection_range), + target_uri: document.uri.as_ref().clone().into(), + target_range: document.line_index.line_col_lsp_range(target_range), + target_selection_range: document + .line_index + .line_col_lsp_range(target_selection_range), + }]); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .definition(); + + let context = CursorContext::new(request); + let actual_links = goto_label_definition(&context, CancellationToken::none()); + + assert!(actual_links.is_none()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .definition(); + + let context = CursorContext::new(request); + let actual_links = goto_label_definition(&context, CancellationToken::none()); + + assert!(actual_links.is_none()); + } +} diff --git a/support/texlab/src/features/definition/string.rs b/support/texlab/src/features/definition/string.rs new file mode 100644 index 0000000000..7162c8b722 --- /dev/null +++ b/support/texlab/src/features/definition/string.rs @@ -0,0 +1,176 @@ +use cancellation::CancellationToken; +use lsp_types::{GotoDefinitionParams, LocationLink}; + +use crate::{ + features::cursor::CursorContext, + syntax::{bibtex, CstNode}, + LineIndexExt, +}; + +pub fn goto_string_definition( + context: &CursorContext<GotoDefinitionParams>, + cancellation_token: &CancellationToken, +) -> Option<Vec<LocationLink>> { + let main_document = context.request.main_document(); + + let data = main_document.data.as_bibtex()?; + let name = context + .cursor + .as_bibtex() + .filter(|token| token.kind() == bibtex::WORD)?; + + bibtex::Token::cast(name.parent())?; + + let origin_selection_range = main_document + .line_index + .line_col_lsp_range(name.text_range()); + + for string in data.root.children().filter_map(bibtex::String::cast) { + cancellation_token.result().ok()?; + + if let Some(string_name) = string.name().filter(|n| n.text() == name.text()) { + return Some(vec![LocationLink { + origin_selection_range: Some(origin_selection_range), + target_uri: main_document.uri.as_ref().clone().into(), + target_selection_range: main_document + .line_index + .line_col_lsp_range(string_name.text_range()), + target_range: main_document + .line_index + .line_col_lsp_range(string.small_range()), + }]); + } + } + + None +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + use lsp_types::Range; + + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .definition(); + + let context = CursorContext::new(request); + let actual_links = goto_string_definition(&context, CancellationToken::none()); + + assert!(actual_links.is_none()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .definition(); + + let context = CursorContext::new(request); + let actual_links = goto_string_definition(&context, CancellationToken::none()); + + assert!(actual_links.is_none()); + } + + #[test] + fn test_simple() { + let tester = FeatureTester::builder() + .files(vec![( + "main.bib", + indoc! { + r#" + @string{foo = {bar}} + @article{bar, author = foo} + "# + }, + )]) + .main("main.bib") + .line(1) + .character(24) + .build(); + let target_uri = tester.uri("main.bib").as_ref().clone().into(); + + let request = tester.definition(); + let context = CursorContext::new(request); + let actual_links = goto_string_definition(&context, CancellationToken::none()).unwrap(); + + let expected_links = vec![LocationLink { + origin_selection_range: Some(Range::new_simple(1, 23, 1, 26)), + target_uri, + target_range: Range::new_simple(0, 0, 0, 20), + target_selection_range: Range::new_simple(0, 8, 0, 11), + }]; + + assert_eq!(actual_links, expected_links); + } + + #[test] + fn concat() { + let tester = FeatureTester::builder() + .files(vec![( + "main.bib", + indoc! { + r#" + @string{foo = {bar}} + @article{bar, author = foo # "bar"} + "# + }, + )]) + .main("main.bib") + .line(1) + .character(24) + .build(); + let target_uri = tester.uri("main.bib").as_ref().clone().into(); + + let request = tester.definition(); + let context = CursorContext::new(request); + let actual_links = goto_string_definition(&context, CancellationToken::none()).unwrap(); + + let expected_links = vec![LocationLink { + origin_selection_range: Some(Range::new_simple(1, 23, 1, 26)), + target_uri, + target_range: Range::new_simple(0, 0, 0, 20), + target_selection_range: Range::new_simple(0, 8, 0, 11), + }]; + + assert_eq!(actual_links, expected_links); + } + + #[test] + fn test_field() { + let tester = FeatureTester::builder() + .files(vec![( + "main.bib", + indoc! { + r#" + @string{foo = {bar}} + @article{bar, author = foo} + "# + }, + )]) + .main("main.bib") + .line(1) + .character(18) + .build(); + + let request = tester.definition(); + let context = CursorContext::new(request); + let actual_links = goto_string_definition(&context, CancellationToken::none()); + + assert!(actual_links.is_none()); + } +} diff --git a/support/texlab/src/features/folding.rs b/support/texlab/src/features/folding.rs new file mode 100644 index 0000000000..0c44b6aa61 --- /dev/null +++ b/support/texlab/src/features/folding.rs @@ -0,0 +1,237 @@ +use cancellation::CancellationToken; +use lsp_types::{FoldingRange, FoldingRangeKind, FoldingRangeParams, Range}; + +use crate::{ + syntax::{bibtex, latex, CstNode}, + DocumentData, LineIndexExt, +}; + +use super::FeatureRequest; + +pub fn find_foldings( + request: FeatureRequest<FoldingRangeParams>, + token: &CancellationToken, +) -> Vec<FoldingRange> { + let mut foldings = Vec::new(); + let main_document = request.main_document(); + match &main_document.data { + DocumentData::Latex(data) => { + for node in data.root.descendants() { + if token.is_canceled() { + break; + } + + if let Some(folding) = latex::Environment::cast(node) + .map(|node| node.small_range()) + .or_else(|| latex::Section::cast(node).map(|node| node.small_range())) + .or_else(|| latex::EnumItem::cast(node).map(|node| node.small_range())) + .map(|node| main_document.line_index.line_col_lsp_range(node)) + .map(create_range) + { + foldings.push(folding); + } + } + } + DocumentData::Bibtex(data) => { + for node in data.root.descendants() { + if token.is_canceled() { + break; + } + + if let Some(folding) = bibtex::Preamble::cast(node) + .map(|node| node.small_range()) + .or_else(|| bibtex::String::cast(node).map(|node| node.small_range())) + .or_else(|| bibtex::Entry::cast(node).map(|node| node.small_range())) + .map(|node| main_document.line_index.line_col_lsp_range(node)) + .map(create_range) + { + foldings.push(folding); + } + } + } + DocumentData::BuildLog(_) => {} + } + foldings +} + +fn create_range(range: Range) -> FoldingRange { + FoldingRange { + start_line: range.start.line, + start_character: Some(range.start.character), + end_line: range.end.line, + end_character: Some(range.end.character), + kind: Some(FoldingRangeKind::Region), + } +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .build() + .folding(); + + let actual_foldings = find_foldings(request, CancellationToken::none()); + assert!(actual_foldings.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .build() + .folding(); + + let actual_foldings = find_foldings(request, CancellationToken::none()); + assert!(actual_foldings.is_empty()); + } + + #[test] + fn test_latex() { + let req = FeatureTester::builder() + .files(vec![( + "main.tex", + indoc! {r#" + \begin{document} + \section{Foo} + foo + \subsection{Bar} + bar + \section{Baz} + baz + \section{Qux} + \end{document}"# }, + )]) + .main("main.tex") + .build() + .folding(); + + let mut actual_foldings = find_foldings(req, CancellationToken::none()); + actual_foldings.sort_by_key(|folding| (folding.start_line, folding.start_character)); + + assert_eq!(actual_foldings.len(), 5); + assert_eq!( + actual_foldings[0], + FoldingRange { + start_line: 0, + start_character: Some(0), + end_line: 8, + end_character: Some(14), + kind: Some(FoldingRangeKind::Region) + } + ); + assert_eq!( + actual_foldings[1], + FoldingRange { + start_line: 1, + start_character: Some(4), + end_line: 4, + end_character: Some(7), + kind: Some(FoldingRangeKind::Region) + } + ); + assert_eq!( + actual_foldings[2], + FoldingRange { + start_line: 3, + start_character: Some(4), + end_line: 4, + end_character: Some(7), + kind: Some(FoldingRangeKind::Region) + } + ); + assert_eq!( + actual_foldings[3], + FoldingRange { + start_line: 5, + start_character: Some(4), + end_line: 6, + end_character: Some(7), + kind: Some(FoldingRangeKind::Region) + } + ); + assert_eq!( + actual_foldings[4], + FoldingRange { + start_line: 7, + start_character: Some(4), + end_line: 7, + end_character: Some(17), + kind: Some(FoldingRangeKind::Region) + } + ); + } + + #[test] + fn test_bibtex() { + let request = FeatureTester::builder() + .files(vec![( + "main.bib", + indoc! {r#" + some junk + here + + @article{foo, + author = {bar}, + title = {baz} + } + + @string{foo = "bar"} + + @comment{foo, + author = {bar}, + title = {baz} + } + + @preamble{"foo"}"# }, + )]) + .main("main.bib") + .build() + .folding(); + + let mut actual_foldings = find_foldings(request, CancellationToken::none()); + actual_foldings.sort_by_key(|folding| (folding.start_line, folding.start_character)); + + assert_eq!(actual_foldings.len(), 3); + assert_eq!( + actual_foldings[0], + FoldingRange { + start_line: 3, + start_character: Some(0), + end_line: 6, + end_character: Some(1), + kind: Some(FoldingRangeKind::Region) + } + ); + assert_eq!( + actual_foldings[1], + FoldingRange { + start_line: 8, + start_character: Some(0), + end_line: 8, + end_character: Some(20), + kind: Some(FoldingRangeKind::Region) + } + ); + assert_eq!( + actual_foldings[2], + FoldingRange { + start_line: 15, + start_character: Some(0), + end_line: 15, + end_character: Some(16), + kind: Some(FoldingRangeKind::Region) + } + ); + } +} diff --git a/support/texlab/src/features/formatting.rs b/support/texlab/src/features/formatting.rs new file mode 100644 index 0000000000..4492a0532d --- /dev/null +++ b/support/texlab/src/features/formatting.rs @@ -0,0 +1,24 @@ +mod bibtex_internal; +mod latexindent; + +use cancellation::CancellationToken; +use lsp_types::{DocumentFormattingParams, TextEdit}; + +use crate::BibtexFormatter; + +use self::{bibtex_internal::format_bibtex_internal, latexindent::format_with_latexindent}; + +use super::FeatureRequest; + +pub fn format_source_code( + request: FeatureRequest<DocumentFormattingParams>, + cancellation_token: &CancellationToken, +) -> Option<Vec<TextEdit>> { + let mut edits = None; + if request.context.options.read().unwrap().bibtex_formatter == BibtexFormatter::Texlab { + edits = edits.or_else(|| format_bibtex_internal(&request, cancellation_token)); + } + + edits = edits.or_else(|| format_with_latexindent(&request, cancellation_token)); + edits +} diff --git a/support/texlab/src/features/formatting/bibtex_internal.rs b/support/texlab/src/features/formatting/bibtex_internal.rs new file mode 100644 index 0000000000..8481f561e7 --- /dev/null +++ b/support/texlab/src/features/formatting/bibtex_internal.rs @@ -0,0 +1,381 @@ +use cancellation::CancellationToken; +use cstree::NodeOrToken; +use lsp_types::{DocumentFormattingParams, TextEdit}; + +use crate::{ + features::FeatureRequest, + syntax::{ + bibtex::{self, HasType}, + CstNode, + }, + LineIndex, LineIndexExt, +}; + +pub fn format_bibtex_internal( + request: &FeatureRequest<DocumentFormattingParams>, + _cancellation_token: &CancellationToken, +) -> Option<Vec<TextEdit>> { + let mut indent = String::new(); + if request.params.options.insert_spaces { + for _ in 0..request.params.options.tab_size { + indent.push(' '); + } + } else { + indent.push('\t'); + } + + let line_length = { + request + .context + .options + .read() + .unwrap() + .formatter_line_length + .map(|value| { + if value <= 0 { + usize::MAX + } else { + value as usize + } + }) + .unwrap_or(80) + }; + + let document = request.main_document(); + let data = document.data.as_bibtex()?; + let mut edits = Vec::new(); + + for node in data.root.children() { + let range = if let Some(entry) = bibtex::Entry::cast(node) { + entry.small_range() + } else if let Some(string) = bibtex::String::cast(node) { + string.small_range() + } else if let Some(preamble) = bibtex::Preamble::cast(node) { + preamble.small_range() + } else { + continue; + }; + + let mut formatter = Formatter::new( + indent.clone(), + request.params.options.tab_size, + line_length, + &document.line_index, + ); + + formatter.visit_node(node); + edits.push(TextEdit { + range: document.line_index.line_col_lsp_range(range), + new_text: formatter.output, + }); + } + + Some(edits) +} + +struct Formatter<'a> { + indent: String, + tab_size: u32, + line_length: usize, + output: String, + align: Vec<usize>, + line_index: &'a LineIndex, +} + +impl<'a> Formatter<'a> { + fn new(indent: String, tab_size: u32, line_length: usize, line_index: &'a LineIndex) -> Self { + Self { + indent, + tab_size, + line_length, + output: String::new(), + align: Vec::new(), + line_index, + } + } + + fn visit_token_lowercase(&mut self, token: &bibtex::SyntaxToken) { + self.output.push_str(&token.text().to_lowercase()); + } + + fn should_insert_space( + &self, + previous: &bibtex::SyntaxToken, + current: &bibtex::SyntaxToken, + ) -> bool { + let previous_range = self.line_index.line_col_lsp_range(previous.text_range()); + let current_range = self.line_index.line_col_lsp_range(current.text_range()); + previous_range.start.line != current_range.start.line + || previous_range.end.character < current_range.start.character + } + + fn base_align(&self) -> usize { + self.output[self.output.rfind('\n').unwrap_or(0)..] + .chars() + .count() + } + + fn visit_node(&mut self, parent: &bibtex::SyntaxNode) { + match parent.kind() { + bibtex::PREAMBLE => { + let preamble = bibtex::Preamble::cast(parent).unwrap(); + self.visit_token_lowercase(preamble.ty().unwrap()); + self.output.push('{'); + if preamble.syntax().arity() > 0 { + self.align.push(self.base_align()); + for node in preamble.syntax().children() { + self.visit_node(node); + } + self.output.push('}'); + } + } + bibtex::STRING => { + let string = bibtex::String::cast(parent).unwrap(); + self.visit_token_lowercase(string.ty().unwrap()); + self.output.push('{'); + if let Some(name) = string.name() { + self.output.push_str(name.text()); + self.output.push_str(" = "); + if let Some(value) = string.value() { + self.align.push(self.base_align()); + self.visit_node(value.syntax()); + self.output.push('}'); + } + } + } + bibtex::ENTRY => { + let entry = bibtex::Entry::cast(parent).unwrap(); + self.visit_token_lowercase(entry.ty().unwrap()); + self.output.push('{'); + if let Some(key) = entry.key() { + self.output.push_str(key.text()); + self.output.push(','); + self.output.push('\n'); + for field in entry.fields() { + self.visit_node(field.syntax()); + } + self.output.push('}'); + } + } + bibtex::FIELD => { + let field = bibtex::Field::cast(parent).unwrap(); + self.output.push_str(&self.indent); + let name = field.name().unwrap(); + self.output.push_str(name.text()); + self.output.push_str(" = "); + if let Some(value) = field.value() { + let count = name.text().chars().count(); + self.align.push(self.tab_size as usize + count + 3); + self.visit_node(value.syntax()); + self.output.push(','); + self.output.push('\n'); + } + } + bibtex::VALUE => { + let tokens: Vec<_> = parent + .descendants_with_tokens() + .filter_map(|element| element.into_token()) + .filter(|token| token.kind() != bibtex::WHITESPACE) + .collect(); + + self.output.push_str(tokens[0].text()); + + let align = self.align.pop().unwrap_or_default(); + let mut length = align + tokens[0].text().chars().count(); + for i in 1..tokens.len() { + let previous = tokens[i - 1]; + let current = tokens[i]; + let current_length = current.text().chars().count(); + + let insert_space = self.should_insert_space(previous, current); + let space_length = if insert_space { 1 } else { 0 }; + + if length + current_length + space_length > self.line_length { + self.output.push('\n'); + self.output.push_str(self.indent.as_ref()); + for _ in 0..=align - self.tab_size as usize { + self.output.push(' '); + } + length = align; + } else if insert_space { + self.output.push(' '); + length += 1; + } + self.output.push_str(current.text()); + length += current_length; + } + } + bibtex::ROOT | bibtex::JUNK | bibtex::COMMENT => { + for element in parent.children_with_tokens() { + match element { + NodeOrToken::Token(token) => { + self.output.push_str(token.text()); + } + NodeOrToken::Node(node) => { + self.visit_node(node); + } + } + } + } + _ => unreachable!(), + } + } +} + +#[cfg(test)] +mod tests { + use insta::{assert_debug_snapshot, assert_display_snapshot}; + + use crate::features::testing::FeatureTester; + + use super::*; + + #[test] + fn test_wrap_long_lines() { + let request = FeatureTester::builder() + .files(vec![( + "main.bib", + "@article{foo, bar = {Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit.},}", + )]) + .main("main.bib") + .build() + .formatting(); + + let edit = format_bibtex_internal(&request, CancellationToken::none()) + .unwrap() + .pop() + .unwrap(); + + assert_display_snapshot!(edit.new_text); + } + + #[test] + fn test_multiple_entries() { + let request = FeatureTester::builder() + .files(vec![( + "main.bib", + "@article{foo, bar = {Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit.},}\n\n@article{foo, bar = {Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit.},}", + )]) + .main("main.bib") + .build() + .formatting(); + + let mut edits = format_bibtex_internal(&request, CancellationToken::none()).unwrap(); + let edit2 = edits.pop().unwrap(); + let edit1 = edits.pop().unwrap(); + + assert_debug_snapshot!((edit1.new_text, edit2.new_text)); + } + + #[test] + fn test_trailing_comma() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@article{foo, bar = baz}")]) + .main("main.bib") + .build() + .formatting(); + + let edit = format_bibtex_internal(&request, CancellationToken::none()) + .unwrap() + .pop() + .unwrap(); + + assert_display_snapshot!(edit.new_text); + } + + #[test] + fn test_insert_braces() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@article{foo, bar = baz,")]) + .main("main.bib") + .build() + .formatting(); + + let edit = format_bibtex_internal(&request, CancellationToken::none()) + .unwrap() + .pop() + .unwrap(); + + assert_display_snapshot!(edit.new_text); + } + + #[test] + fn test_command() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@article{foo, bar = \"\\baz\",}")]) + .main("main.bib") + .build() + .formatting(); + + let edit = format_bibtex_internal(&request, CancellationToken::none()) + .unwrap() + .pop() + .unwrap(); + + assert_display_snapshot!(edit.new_text); + } + + #[test] + fn test_concatenation() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@article{foo, bar = \"baz\" # \"qux\"}")]) + .main("main.bib") + .build() + .formatting(); + + let edit = format_bibtex_internal(&request, CancellationToken::none()) + .unwrap() + .pop() + .unwrap(); + + assert_display_snapshot!(edit.new_text); + } + + #[test] + fn test_parens() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@article(foo,)")]) + .main("main.bib") + .build() + .formatting(); + + let edit = format_bibtex_internal(&request, CancellationToken::none()) + .unwrap() + .pop() + .unwrap(); + + assert_display_snapshot!(edit.new_text); + } + + #[test] + fn test_string() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@string{foo=\"bar\"}")]) + .main("main.bib") + .build() + .formatting(); + + let edit = format_bibtex_internal(&request, CancellationToken::none()) + .unwrap() + .pop() + .unwrap(); + + assert_display_snapshot!(edit.new_text); + } + + #[test] + fn test_preamble() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "@preamble{\n\"foo bar baz\"}")]) + .main("main.bib") + .build() + .formatting(); + + let edit = format_bibtex_internal(&request, CancellationToken::none()) + .unwrap() + .pop() + .unwrap(); + + assert_display_snapshot!(edit.new_text); + } +} diff --git a/support/texlab/src/features/formatting/latexindent.rs b/support/texlab/src/features/formatting/latexindent.rs new file mode 100644 index 0000000000..f603728a5d --- /dev/null +++ b/support/texlab/src/features/formatting/latexindent.rs @@ -0,0 +1,50 @@ +use std::{ + io::{BufWriter, Write}, + process::{Command, Stdio}, +}; + +use cancellation::CancellationToken; +use cstree::{TextLen, TextRange}; +use lsp_types::{DocumentFormattingParams, TextEdit}; + +use crate::{features::FeatureRequest, LineIndexExt}; + +pub fn format_with_latexindent( + request: &FeatureRequest<DocumentFormattingParams>, + _cancellation_token: &CancellationToken, +) -> Option<Vec<TextEdit>> { + let document = request.main_document(); + + let current_dir = &request.context.current_directory; + let options = request.context.options.read().unwrap(); + let current_dir = match &options.root_directory { + Some(root_directory) => current_dir.join(root_directory), + None => current_dir.clone(), + }; + drop(options); + + let mut process = Command::new("latexindent") + .arg("-l") + .current_dir(current_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + let stdin = process.stdin.take()?; + let mut stdin = BufWriter::new(stdin); + stdin.write_all(document.text.as_bytes()).ok()?; + drop(stdin); + + let output = process.wait_with_output().ok()?; + + let new_text = String::from_utf8_lossy(&output.stdout).into_owned(); + + Some(vec![TextEdit { + range: document + .line_index + .line_col_lsp_range(TextRange::new(0.into(), document.text.text_len())), + new_text, + }]) +} diff --git a/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__command.snap b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__command.snap new file mode 100644 index 0000000000..792e7be277 --- /dev/null +++ b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__command.snap @@ -0,0 +1,8 @@ +--- +source: src/features/formatting/bibtex_internal.rs +expression: edit.new_text + +--- +@article{foo, + bar = "\baz", +} diff --git a/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__concatenation.snap b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__concatenation.snap new file mode 100644 index 0000000000..8cd51dfa92 --- /dev/null +++ b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__concatenation.snap @@ -0,0 +1,8 @@ +--- +source: src/features/formatting/bibtex_internal.rs +expression: edit.new_text + +--- +@article{foo, + bar = "baz" # "qux", +} diff --git a/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__insert_braces.snap b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__insert_braces.snap new file mode 100644 index 0000000000..3be9b15e9d --- /dev/null +++ b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__insert_braces.snap @@ -0,0 +1,8 @@ +--- +source: src/features/formatting/bibtex_internal.rs +expression: edit.new_text + +--- +@article{foo, + bar = baz, +} diff --git a/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__multiple_entries.snap b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__multiple_entries.snap new file mode 100644 index 0000000000..636b4b665c --- /dev/null +++ b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__multiple_entries.snap @@ -0,0 +1,9 @@ +--- +source: src/features/formatting/bibtex_internal.rs +expression: "(edit1.new_text, edit2.new_text)" + +--- +( + "@article{foo,\n\tbar = {Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum\n\t dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet,\n\t consectetur adipiscing elit.},\n}", + "@article{foo,\n\tbar = {Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum\n\t dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet,\n\t consectetur adipiscing elit.},\n}", +) diff --git a/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__parens.snap b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__parens.snap new file mode 100644 index 0000000000..5aff28cb5f --- /dev/null +++ b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__parens.snap @@ -0,0 +1,7 @@ +--- +source: src/features/formatting/bibtex_internal.rs +expression: edit.new_text + +--- +@article{foo, +} diff --git a/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__preamble.snap b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__preamble.snap new file mode 100644 index 0000000000..663569eb5b --- /dev/null +++ b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__preamble.snap @@ -0,0 +1,6 @@ +--- +source: src/features/formatting/bibtex_internal.rs +expression: edit.new_text + +--- +@preamble{"foo bar baz"} diff --git a/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__string.snap b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__string.snap new file mode 100644 index 0000000000..6d37a69e8e --- /dev/null +++ b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__string.snap @@ -0,0 +1,6 @@ +--- +source: src/features/formatting/bibtex_internal.rs +expression: edit.new_text + +--- +@string{foo = "bar"} diff --git a/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__trailing_comma.snap b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__trailing_comma.snap new file mode 100644 index 0000000000..3be9b15e9d --- /dev/null +++ b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__trailing_comma.snap @@ -0,0 +1,8 @@ +--- +source: src/features/formatting/bibtex_internal.rs +expression: edit.new_text + +--- +@article{foo, + bar = baz, +} diff --git a/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__wrap_long_lines.snap b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__wrap_long_lines.snap new file mode 100644 index 0000000000..6c0ec724b5 --- /dev/null +++ b/support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__wrap_long_lines.snap @@ -0,0 +1,10 @@ +--- +source: src/features/formatting/bibtex_internal.rs +expression: edit.new_text + +--- +@article{foo, + bar = {Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum + dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, + consectetur adipiscing elit.}, +} diff --git a/support/texlab/src/features/forward_search.rs b/support/texlab/src/features/forward_search.rs new file mode 100644 index 0000000000..39470a64ec --- /dev/null +++ b/support/texlab/src/features/forward_search.rs @@ -0,0 +1,124 @@ +use std::{ + io, + path::Path, + process::{Command, Stdio}, +}; + +use cancellation::CancellationToken; +use log::error; +use lsp_types::TextDocumentPositionParams; +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; + +use super::FeatureRequest; + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize_repr, Deserialize_repr)] +#[repr(i32)] +pub enum ForwardSearchStatus { + Success = 0, + Error = 1, + Failure = 2, + Unconfigured = 3, +} + +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +pub struct ForwardSearchResult { + pub status: ForwardSearchStatus, +} + +pub fn execute_forward_search( + request: FeatureRequest<TextDocumentPositionParams>, + _cancellation_token: &CancellationToken, +) -> Option<ForwardSearchResult> { + let options = { + request + .context + .options + .read() + .unwrap() + .forward_search + .clone() + .unwrap_or_default() + }; + + if options.executable.is_none() || options.args.is_none() { + return Some(ForwardSearchResult { + status: ForwardSearchStatus::Unconfigured, + }); + } + + let root_document = request + .subset + .documents + .iter() + .find(|document| { + if let Some(data) = document.data.as_latex() { + data.extras.has_document_environment + && !data + .extras + .explicit_links + .iter() + .filter_map(|link| link.as_component_name()) + .any(|name| name == "subfiles.cls") + } else { + false + } + }) + .filter(|document| document.uri.scheme() == "file")?; + + let data = root_document.data.as_latex()?; + let pdf_path = data + .extras + .implicit_links + .pdf + .iter() + .filter_map(|uri| uri.to_file_path().ok()) + .find(|path| path.exists())?; + + let tex_path = request.main_document().uri.to_file_path().ok()?; + + let args: Vec<String> = options + .args + .unwrap() + .into_iter() + .flat_map(|arg| { + replace_placeholder(&tex_path, &pdf_path, request.params.position.line, arg) + }) + .collect(); + + let status = match run_process(options.executable.unwrap(), args) { + Ok(()) => ForwardSearchStatus::Success, + Err(why) => { + error!("Unable to execute forward search: {}", why); + ForwardSearchStatus::Failure + } + }; + Some(ForwardSearchResult { status }) +} + +fn replace_placeholder( + tex_file: &Path, + pdf_file: &Path, + line_number: u32, + argument: String, +) -> Option<String> { + let result = if argument.starts_with('"') || argument.ends_with('"') { + argument + } else { + argument + .replace("%f", tex_file.to_str()?) + .replace("%p", pdf_file.to_str()?) + .replace("%l", &(line_number + 1).to_string()) + }; + Some(result) +} + +fn run_process(executable: String, args: Vec<String>) -> io::Result<()> { + Command::new(executable) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + Ok(()) +} diff --git a/support/texlab/src/features/highlight.rs b/support/texlab/src/features/highlight.rs new file mode 100644 index 0000000000..e1aea6e2ad --- /dev/null +++ b/support/texlab/src/features/highlight.rs @@ -0,0 +1,15 @@ +mod label; + +use cancellation::CancellationToken; +use lsp_types::{DocumentHighlight, DocumentHighlightParams}; + +use self::label::find_label_highlights; + +use super::FeatureRequest; + +pub fn find_document_highlights( + request: FeatureRequest<DocumentHighlightParams>, + token: &CancellationToken, +) -> Option<Vec<DocumentHighlight>> { + find_label_highlights(&request, token) +} diff --git a/support/texlab/src/features/highlight/label.rs b/support/texlab/src/features/highlight/label.rs new file mode 100644 index 0000000000..4c94180f2a --- /dev/null +++ b/support/texlab/src/features/highlight/label.rs @@ -0,0 +1,167 @@ +use cancellation::CancellationToken; +use lsp_types::{DocumentHighlight, DocumentHighlightKind, DocumentHighlightParams}; + +use crate::{ + features::FeatureRequest, + syntax::{latex, CstNode}, + LineIndexExt, +}; + +pub fn find_label_highlights( + request: &FeatureRequest<DocumentHighlightParams>, + token: &CancellationToken, +) -> Option<Vec<DocumentHighlight>> { + let main_document = request.main_document(); + + let offset = main_document + .line_index + .offset_lsp(request.params.text_document_position_params.position); + + let data = main_document.data.as_latex()?; + let name = data.root.token_at_offset(offset).right_biased()?; + if name.kind() != latex::WORD { + return None; + } + + if !matches!( + name.parent().parent()?.kind(), + latex::LABEL_DEFINITION | latex::LABEL_REFERENCE | latex::LABEL_REFERENCE_RANGE + ) { + return None; + } + + let mut highlights = Vec::new(); + for node in data.root.descendants() { + if token.is_canceled() { + return None; + } + + if let Some(label_name) = latex::LabelDefinition::cast(node) + .and_then(|label| label.name()) + .and_then(|label_name| label_name.word()) + .filter(|label_name| label_name.text() == name.text()) + { + let range = main_document + .line_index + .line_col_lsp_range(label_name.text_range()); + + highlights.push(DocumentHighlight { + range, + kind: Some(DocumentHighlightKind::Write), + }); + } else if let Some(label) = latex::LabelReference::cast(node) { + for label_name in label + .name_list() + .into_iter() + .flat_map(|name| name.words()) + .filter(|label_name| label_name.text() == name.text()) + { + let range = main_document + .line_index + .line_col_lsp_range(label_name.text_range()); + + highlights.push(DocumentHighlight { + range, + kind: Some(DocumentHighlightKind::Read), + }); + } + } else if let Some(label) = latex::LabelReferenceRange::cast(node) { + if let Some(label_name) = label + .from() + .and_then(|label_name| label_name.word()) + .filter(|label_name| label_name.text() == name.text()) + { + let range = main_document + .line_index + .line_col_lsp_range(label_name.text_range()); + + highlights.push(DocumentHighlight { + range, + kind: Some(DocumentHighlightKind::Read), + }); + } + + if let Some(label_name) = label + .to() + .and_then(|label_name| label_name.word()) + .filter(|label_name| label_name.text() == name.text()) + { + let range = main_document + .line_index + .line_col_lsp_range(label_name.text_range()); + + highlights.push(DocumentHighlight { + range, + kind: Some(DocumentHighlightKind::Read), + }); + } + } + } + + Some(highlights) +} + +#[cfg(test)] +mod tests { + use lsp_types::Range; + + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .highlight(); + + let actual_links = find_label_highlights(&request, CancellationToken::none()); + + assert!(actual_links.is_none()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .highlight(); + + let actual_links = find_label_highlights(&request, CancellationToken::none()); + + assert!(actual_links.is_none()); + } + + #[test] + fn test_label() { + let tester = FeatureTester::builder() + .files(vec![("main.tex", "\\label{foo}\n\\ref{foo}\\label{bar}")]) + .main("main.tex") + .line(0) + .character(7) + .build(); + let request = tester.highlight(); + + let actual_highlights = find_label_highlights(&request, CancellationToken::none()).unwrap(); + + let expected_highlights = vec![ + DocumentHighlight { + range: Range::new_simple(0, 7, 0, 10), + kind: Some(DocumentHighlightKind::Write), + }, + DocumentHighlight { + range: Range::new_simple(1, 5, 1, 8), + kind: Some(DocumentHighlightKind::Read), + }, + ]; + + assert_eq!(actual_highlights, expected_highlights); + } +} diff --git a/support/texlab/src/features/hover.rs b/support/texlab/src/features/hover.rs new file mode 100644 index 0000000000..a78978bc33 --- /dev/null +++ b/support/texlab/src/features/hover.rs @@ -0,0 +1,43 @@ +#[cfg(feature = "citeproc")] +mod citation; +mod component; +mod entry_type; +mod field; +mod label; +mod string_ref; + +use cancellation::CancellationToken; +use cfg_if::cfg_if; +use lsp_types::{Hover, HoverParams}; + +use crate::features::cursor::CursorContext; + +use self::{ + component::find_component_hover, entry_type::find_entry_type_hover, field::find_field_hover, + label::find_label_hover, string_ref::find_string_reference_hover, +}; + +use super::FeatureRequest; + +pub fn find_hover( + request: FeatureRequest<HoverParams>, + cabcellation_token: &CancellationToken, +) -> Option<Hover> { + let context = CursorContext::new(request); + log::debug!("[Hover] Cursor: {:?}", context.cursor); + let mut hover = find_label_hover(&context, cabcellation_token); + + cfg_if! { + if #[cfg(feature = "citation")] { + hover = hover.or_else(|| self::citation::find_citation_hover(&context, cabcellation_token)); + } + } + + hover = hover + .or_else(|| find_component_hover(&context, cabcellation_token)) + .or_else(|| find_string_reference_hover(&context, cabcellation_token)) + .or_else(|| find_field_hover(&context, cabcellation_token)) + .or_else(|| find_entry_type_hover(&context, cabcellation_token)); + + hover +} diff --git a/support/texlab/src/features/hover/citation.rs b/support/texlab/src/features/hover/citation.rs new file mode 100644 index 0000000000..1fd6d6c06b --- /dev/null +++ b/support/texlab/src/features/hover/citation.rs @@ -0,0 +1,133 @@ +use cancellation::CancellationToken; +use lsp_types::{Hover, HoverContents, HoverParams}; + +use crate::{citation, features::cursor::CursorContext, LineIndexExt}; + +pub fn find_citation_hover( + context: &CursorContext<HoverParams>, + cancellation_token: &CancellationToken, +) -> Option<Hover> { + cancellation_token.result().ok()?; + let main_document = context.request.main_document(); + + let (key_text, key_range) = context + .find_citation_key_word() + .or_else(|| context.find_citation_key_command()) + .or_else(|| context.find_entry_key())?; + + let contents = context + .request + .subset + .documents + .iter() + .find_map(|document| { + document + .data + .as_bibtex() + .and_then(|data| citation::render_citation(&data.root, &key_text)) + })?; + + Some(Hover { + range: Some(main_document.line_index.line_col_lsp_range(key_range)), + contents: HoverContents::Markup(contents), + }) +} + +#[cfg(test)] +mod tests { + use lsp_types::{MarkupContent, MarkupKind, Range}; + + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_citation_hover(&context, CancellationToken::none()); + + assert_eq!(actual_hover, None); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_citation_hover(&context, CancellationToken::none()); + + assert_eq!(actual_hover, None); + } + + #[test] + fn test_inside_cite() { + let request = FeatureTester::builder() + .files(vec![ + ( + "main.bib", + "@article{foo, author = {Foo Bar}, title = {Baz Qux}, year = 1337}", + ), + ("main.tex", "\\addbibresource{main.bib}\n\\cite{foo}"), + ]) + .main("main.tex") + .line(1) + .character(7) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_citation_hover(&context, CancellationToken::none()).unwrap(); + + let expected_hover = Hover { + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value: "Bar, F. (1337). *Baz Qux*.".into(), + }), + range: Some(Range::new_simple(1, 6, 1, 9)), + }; + assert_eq!(actual_hover, expected_hover); + } + + #[test] + fn test_inside_entry() { + let request = FeatureTester::builder() + .files(vec![ + ( + "main.bib", + "@article{foo, author = {Foo Bar}, title = {Baz Qux}, year = 1337}", + ), + ("main.tex", "\\addbibresource{main.bib}\n\\cite{foo}"), + ]) + .main("main.bib") + .line(0) + .character(11) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_citation_hover(&context, CancellationToken::none()).unwrap(); + + let expected_hover = Hover { + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value: "Bar, F. (1337). *Baz Qux*.".into(), + }), + range: Some(Range::new_simple(0, 9, 0, 12)), + }; + assert_eq!(actual_hover, expected_hover); + } +} diff --git a/support/texlab/src/features/hover/component.rs b/support/texlab/src/features/hover/component.rs new file mode 100644 index 0000000000..abfdb781f1 --- /dev/null +++ b/support/texlab/src/features/hover/component.rs @@ -0,0 +1,105 @@ +use cancellation::CancellationToken; +use lsp_types::{Hover, HoverContents, HoverParams}; + +use crate::{ + component_db::COMPONENT_DATABASE, features::cursor::CursorContext, syntax::latex, LineIndexExt, +}; + +pub fn find_component_hover( + context: &CursorContext<HoverParams>, + token: &CancellationToken, +) -> Option<Hover> { + let main_document = context.request.main_document(); + let data = main_document.data.as_latex()?; + for link in &data.extras.explicit_links { + if token.is_canceled() { + break; + } + + if matches!( + link.kind, + latex::ExplicitLinkKind::Package | latex::ExplicitLinkKind::Class + ) && link.stem_range.contains_inclusive(context.offset) + { + let docs = COMPONENT_DATABASE.documentation(&link.stem)?; + return Some(Hover { + contents: HoverContents::Markup(docs), + range: Some(main_document.line_index.line_col_lsp_range(link.stem_range)), + }); + } + } + None +} + +#[cfg(test)] +mod tests { + use lsp_types::Range; + + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_component_hover(&context, CancellationToken::none()); + + assert_eq!(actual_hover, None); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_component_hover(&context, CancellationToken::none()); + + assert_eq!(actual_hover, None); + } + + #[test] + fn test_known_package() { + let request = FeatureTester::builder() + .files(vec![("main.tex", r#"\usepackage{amsmath}"#)]) + .main("main.tex") + .line(0) + .character(15) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_component_hover(&context, CancellationToken::none()).unwrap(); + + assert_eq!(actual_hover.range.unwrap(), Range::new_simple(0, 12, 0, 19)); + } + + #[test] + fn test_unknown_class() { + let request = FeatureTester::builder() + .files(vec![("main.tex", r#"\documentclass{abcdefghijklmnop}"#)]) + .main("main.tex") + .line(0) + .character(20) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_component_hover(&context, CancellationToken::none()); + + assert_eq!(actual_hover, None); + } +} diff --git a/support/texlab/src/features/hover/entry_type.rs b/support/texlab/src/features/hover/entry_type.rs new file mode 100644 index 0000000000..e480b2f835 --- /dev/null +++ b/support/texlab/src/features/hover/entry_type.rs @@ -0,0 +1,128 @@ +use cancellation::CancellationToken; +use lsp_types::{Hover, HoverContents, HoverParams, MarkupContent}; + +use crate::{features::cursor::CursorContext, LineIndexExt, LANGUAGE_DATA}; + +pub fn find_entry_type_hover( + context: &CursorContext<HoverParams>, + _token: &CancellationToken, +) -> Option<Hover> { + let name = context + .cursor + .as_bibtex() + .filter(|token| token.kind().is_type())?; + + let docs = LANGUAGE_DATA.entry_type_documentation(&name.text()[1..])?; + Some(Hover { + contents: HoverContents::Markup(MarkupContent { + kind: lsp_types::MarkupKind::Markdown, + value: docs.to_string(), + }), + range: Some( + context + .request + .main_document() + .line_index + .line_col_lsp_range(name.text_range()), + ), + }) +} + +#[cfg(test)] +mod tests { + use lsp_types::{MarkupKind, Range}; + + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_entry_type_hover(&context, CancellationToken::none()); + + assert_eq!(actual_hover, None); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_entry_type_hover(&context, CancellationToken::none()); + + assert_eq!(actual_hover, None); + } + + #[test] + fn test_known_entry_type() { + let request = FeatureTester::builder() + .files(vec![("main.bib", r#"@article{foo,}"#)]) + .main("main.bib") + .line(0) + .character(3) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_entry_type_hover(&context, CancellationToken::none()).unwrap(); + + let expected_hover = Hover { + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value: LANGUAGE_DATA + .entry_type_documentation("article") + .unwrap() + .into(), + }), + range: Some(Range::new_simple(0, 0, 0, 8)), + }; + assert_eq!(actual_hover, expected_hover); + } + + #[test] + fn test_unknown_field() { + let request = FeatureTester::builder() + .files(vec![("main.bib", r#"@foo{bar,}"#)]) + .main("main.bib") + .line(0) + .character(3) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_entry_type_hover(&context, CancellationToken::none()); + + assert_eq!(actual_hover, None); + } + + #[test] + fn test_entry_key() { + let request = FeatureTester::builder() + .files(vec![("main.bib", r#"@article{foo,}"#)]) + .main("main.bib") + .line(0) + .character(11) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_entry_type_hover(&context, CancellationToken::none()); + + assert_eq!(actual_hover, None); + } +} diff --git a/support/texlab/src/features/hover/field.rs b/support/texlab/src/features/hover/field.rs new file mode 100644 index 0000000000..54a8718d69 --- /dev/null +++ b/support/texlab/src/features/hover/field.rs @@ -0,0 +1,128 @@ +use cancellation::CancellationToken; +use lsp_types::{Hover, HoverContents, HoverParams, MarkupContent}; + +use crate::{ + features::cursor::CursorContext, + syntax::{bibtex, CstNode}, + LineIndexExt, LANGUAGE_DATA, +}; + +pub fn find_field_hover( + context: &CursorContext<HoverParams>, + _token: &CancellationToken, +) -> Option<Hover> { + let main_document = context.request.main_document(); + + let name = context + .cursor + .as_bibtex() + .filter(|token| token.kind() == bibtex::WORD)?; + + bibtex::Field::cast(name.parent())?; + + let docs = LANGUAGE_DATA.field_documentation(&name.text())?; + Some(Hover { + contents: HoverContents::Markup(MarkupContent { + kind: lsp_types::MarkupKind::Markdown, + value: docs.to_string(), + }), + range: Some( + main_document + .line_index + .line_col_lsp_range(name.text_range()), + ), + }) +} + +#[cfg(test)] +mod tests { + use lsp_types::{MarkupKind, Range}; + + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_field_hover(&context, CancellationToken::none()); + + assert_eq!(actual_hover, None); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_field_hover(&context, CancellationToken::none()); + + assert_eq!(actual_hover, None); + } + + #[test] + fn test_known_field() { + let request = FeatureTester::builder() + .files(vec![("main.bib", r#"@article{foo, author = bar}"#)]) + .main("main.bib") + .line(0) + .character(15) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_field_hover(&context, CancellationToken::none()).unwrap(); + let expected_hover = Hover { + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value: LANGUAGE_DATA.field_documentation("author").unwrap().into(), + }), + range: Some(Range::new_simple(0, 14, 0, 20)), + }; + assert_eq!(actual_hover, expected_hover); + } + + #[test] + fn test_unknown_field() { + let request = FeatureTester::builder() + .files(vec![("main.bib", r#"@article{foo, bar = baz}"#)]) + .main("main.bib") + .line(0) + .character(15) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_field_hover(&context, CancellationToken::none()); + assert_eq!(actual_hover, None); + } + + #[test] + fn test_entry_key() { + let request = FeatureTester::builder() + .files(vec![("main.bib", r#"@article{foo, author = bar}"#)]) + .main("main.bib") + .line(0) + .character(11) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_field_hover(&context, CancellationToken::none()); + assert_eq!(actual_hover, None); + } +} diff --git a/support/texlab/src/features/hover/label.rs b/support/texlab/src/features/hover/label.rs new file mode 100644 index 0000000000..79afe7d250 --- /dev/null +++ b/support/texlab/src/features/hover/label.rs @@ -0,0 +1,79 @@ +use cancellation::CancellationToken; +use lsp_types::{Hover, HoverContents, HoverParams}; + +use crate::{features::cursor::CursorContext, render_label, LineIndexExt}; + +pub fn find_label_hover( + context: &CursorContext<HoverParams>, + _token: &CancellationToken, +) -> Option<Hover> { + let main_document = context.request.main_document(); + + let (name_text, name_range) = context + .find_label_name_word() + .or_else(|| context.find_label_name_command())?; + + let label = render_label(&context.request.subset, name_text, None)?; + + Some(Hover { + range: Some(main_document.line_index.line_col_lsp_range(name_range)), + contents: HoverContents::Markup(label.documentation()), + }) +} + +#[cfg(test)] +mod tests { + use lsp_types::Range; + + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_label_hover(&context, CancellationToken::none()); + + assert_eq!(actual_hover, None); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_label_hover(&context, CancellationToken::none()); + + assert_eq!(actual_hover, None); + } + + #[test] + fn test_section() { + let request = FeatureTester::builder() + .files(vec![("main.tex", r#"\section{Foo}\label{sec:foo}"#)]) + .main("main.tex") + .line(0) + .character(23) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_label_hover(&context, CancellationToken::none()).unwrap(); + + assert_eq!(actual_hover.range.unwrap(), Range::new_simple(0, 20, 0, 27)); + } +} diff --git a/support/texlab/src/features/hover/string_ref.rs b/support/texlab/src/features/hover/string_ref.rs new file mode 100644 index 0000000000..f6e7f1c873 --- /dev/null +++ b/support/texlab/src/features/hover/string_ref.rs @@ -0,0 +1,141 @@ +use cancellation::CancellationToken; +use lsp_types::{Hover, HoverContents, HoverParams, MarkupContent, MarkupKind}; + +use crate::{ + features::cursor::CursorContext, + syntax::{bibtex, CstNode}, + LineIndexExt, +}; + +pub fn find_string_reference_hover( + context: &CursorContext<HoverParams>, + cancellation_token: &CancellationToken, +) -> Option<Hover> { + let main_document = context.request.main_document(); + let data = main_document.data.as_bibtex()?; + + let name = context + .cursor + .as_bibtex() + .filter(|token| token.kind() == bibtex::WORD) + .filter(|name| matches!(name.parent().kind(), bibtex::TOKEN | bibtex::STRING))?; + + for string in data.root.children().filter_map(bibtex::String::cast) { + if cancellation_token.is_canceled() { + return None; + } + + if string.name().filter(|n| n.text() == name.text()).is_some() { + let value = string.value()?.syntax().text().to_string(); + return Some(Hover { + range: Some( + main_document + .line_index + .line_col_lsp_range(name.text_range()), + ), + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::PlainText, + value, + }), + }); + } + } + + None +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + use lsp_types::Range; + + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_string_reference_hover(&context, CancellationToken::none()); + + assert_eq!(actual_hover, None); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_string_reference_hover(&context, CancellationToken::none()); + + assert_eq!(actual_hover, None); + } + + #[test] + fn test_inside_reference() { + let request = FeatureTester::builder() + .files(vec![( + "main.bib", + indoc! { r#" + @string{foo = "Foo"} + @string{bar = "Bar"} + @article{baz, author = bar} + "# }, + )]) + .main("main.bib") + .line(2) + .character(24) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = + find_string_reference_hover(&context, CancellationToken::none()).unwrap(); + + let expected_hover = Hover { + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::PlainText, + value: "\"Bar\"".into(), + }), + range: Some(Range::new_simple(2, 23, 2, 26)), + }; + + assert_eq!(actual_hover, expected_hover); + } + + #[test] + fn test_inside_field() { + let request = FeatureTester::builder() + .files(vec![( + "main.bib", + indoc! { r#" + @string{foo = "Foo"} + @string{bar = "Bar"} + @article{baz, author = bar} + "# }, + )]) + .main("main.bib") + .line(2) + .character(20) + .build() + .hover(); + + let context = CursorContext::new(request); + let actual_hover = find_string_reference_hover(&context, CancellationToken::none()); + assert_eq!(actual_hover, None); + } +} diff --git a/support/texlab/src/features/link.rs b/support/texlab/src/features/link.rs new file mode 100644 index 0000000000..a6016c9f76 --- /dev/null +++ b/support/texlab/src/features/link.rs @@ -0,0 +1,115 @@ +use cancellation::CancellationToken; +use lsp_types::{DocumentLink, DocumentLinkParams}; + +use crate::LineIndexExt; + +use super::FeatureRequest; + +pub fn find_document_links( + request: FeatureRequest<DocumentLinkParams>, + cancellation_token: &CancellationToken, +) -> Vec<DocumentLink> { + let mut links = Vec::new(); + let main_document = request.main_document(); + if let Some(data) = main_document.data.as_latex() { + for include in &data.extras.explicit_links { + for target in &include.targets { + if cancellation_token.is_canceled() { + return links; + } + + if request + .subset + .documents + .iter() + .any(|document| document.uri.as_ref() == target.as_ref()) + { + links.push(DocumentLink { + range: main_document + .line_index + .line_col_lsp_range(include.stem_range), + target: Some(target.as_ref().clone().into()), + tooltip: None, + data: None, + }); + break; + } + } + } + } + links +} + +#[cfg(test)] +mod tests { + use super::*; + + use lsp_types::Range; + + use crate::{features::testing::FeatureTester, RangeExt}; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .build() + .link(); + + let items = find_document_links(request, CancellationToken::none()); + assert!(items.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .build() + .link(); + + let items = find_document_links(request, CancellationToken::none()); + assert!(items.is_empty()); + } + + #[test] + fn test_includes() { + let tester = FeatureTester::builder() + .files(vec![("foo.tex", r#"\input{bar.tex}"#), ("bar.tex", r#""#)]) + .main("foo.tex") + .build(); + let target = tester.uri("bar.tex"); + + let actual_items = find_document_links(tester.link(), CancellationToken::none()); + + let expected_items = vec![DocumentLink { + range: Range::new_simple(0, 7, 0, 14), + target: Some(target.as_ref().clone().into()), + tooltip: None, + data: None, + }]; + assert_eq!(actual_items, expected_items); + } + + #[test] + fn test_imports() { + let tester = FeatureTester::builder() + .files(vec![ + ("foo.tex", r#"\import{bar/}{baz}"#), + ("bar/baz.tex", r#""#), + ]) + .main("foo.tex") + .build(); + let target = tester.uri("bar/baz.tex"); + + let actual_items = find_document_links(tester.link(), CancellationToken::none()); + + let expected_items = vec![DocumentLink { + range: Range::new_simple(0, 14, 0, 17), + target: Some(target.as_ref().clone().into()), + tooltip: None, + data: None, + }]; + assert_eq!(actual_items, expected_items); + } +} diff --git a/support/texlab/src/features/lsp_kinds.rs b/support/texlab/src/features/lsp_kinds.rs new file mode 100644 index 0000000000..fbfc50cace --- /dev/null +++ b/support/texlab/src/features/lsp_kinds.rs @@ -0,0 +1,94 @@ +use lsp_types::{CompletionItemKind, SymbolKind}; + +use crate::BibtexEntryTypeCategory; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum Structure { + Command, + Snippet, + Environment, + Section, + Float, + Theorem, + Equation, + Item, + Label, + Folder, + File, + PgfLibrary, + TikzLibrary, + Color, + ColorModel, + Package, + Class, + Entry(BibtexEntryTypeCategory), + Field, + Argument, + GlossaryEntry, +} + +impl Structure { + pub fn completion_kind(self) -> CompletionItemKind { + match self { + Self::Command => CompletionItemKind::Function, + Self::Snippet => CompletionItemKind::Snippet, + Self::Environment => CompletionItemKind::Enum, + Self::Section => CompletionItemKind::Module, + Self::Float => CompletionItemKind::Method, + Self::Theorem => CompletionItemKind::Variable, + Self::Equation => CompletionItemKind::Constant, + Self::Item => CompletionItemKind::EnumMember, + Self::Label => CompletionItemKind::Constructor, + Self::Folder => CompletionItemKind::Folder, + Self::File => CompletionItemKind::File, + Self::PgfLibrary => CompletionItemKind::Property, + Self::TikzLibrary => CompletionItemKind::Property, + Self::Color => CompletionItemKind::Color, + Self::ColorModel => CompletionItemKind::Color, + Self::Package => CompletionItemKind::Class, + Self::Class => CompletionItemKind::Class, + Self::Entry(BibtexEntryTypeCategory::Misc) => CompletionItemKind::Interface, + Self::Entry(BibtexEntryTypeCategory::String) => CompletionItemKind::Text, + Self::Entry(BibtexEntryTypeCategory::Article) => CompletionItemKind::Event, + Self::Entry(BibtexEntryTypeCategory::Book) => CompletionItemKind::Struct, + Self::Entry(BibtexEntryTypeCategory::Collection) => CompletionItemKind::TypeParameter, + Self::Entry(BibtexEntryTypeCategory::Part) => CompletionItemKind::Operator, + Self::Entry(BibtexEntryTypeCategory::Thesis) => CompletionItemKind::Unit, + Self::Field => CompletionItemKind::Field, + Self::Argument => CompletionItemKind::Value, + Self::GlossaryEntry => CompletionItemKind::Keyword, + } + } + + pub fn symbol_kind(self) -> SymbolKind { + match self { + Self::Command => SymbolKind::Function, + Self::Snippet => unimplemented!(), + Self::Environment => SymbolKind::Enum, + Self::Section => SymbolKind::Module, + Self::Float => SymbolKind::Method, + Self::Theorem => SymbolKind::Variable, + Self::Equation => SymbolKind::Constant, + Self::Item => SymbolKind::EnumMember, + Self::Label => SymbolKind::Constructor, + Self::Folder => SymbolKind::Namespace, + Self::File => SymbolKind::File, + Self::PgfLibrary => SymbolKind::Property, + Self::TikzLibrary => SymbolKind::Property, + Self::Color => unimplemented!(), + Self::ColorModel => unimplemented!(), + Self::Package => SymbolKind::Class, + Self::Class => SymbolKind::Class, + Self::Entry(BibtexEntryTypeCategory::Misc) => SymbolKind::Interface, + Self::Entry(BibtexEntryTypeCategory::String) => SymbolKind::String, + Self::Entry(BibtexEntryTypeCategory::Article) => SymbolKind::Event, + Self::Entry(BibtexEntryTypeCategory::Book) => SymbolKind::Struct, + Self::Entry(BibtexEntryTypeCategory::Collection) => SymbolKind::TypeParameter, + Self::Entry(BibtexEntryTypeCategory::Part) => SymbolKind::Operator, + Self::Entry(BibtexEntryTypeCategory::Thesis) => SymbolKind::Object, + Self::Field => SymbolKind::Field, + Self::Argument => SymbolKind::Number, + Self::GlossaryEntry => unimplemented!(), + } + } +} diff --git a/support/texlab/src/features/reference.rs b/support/texlab/src/features/reference.rs new file mode 100644 index 0000000000..14cf3bc297 --- /dev/null +++ b/support/texlab/src/features/reference.rs @@ -0,0 +1,25 @@ +mod entry; +mod label; +mod string; + +use cancellation::CancellationToken; +use lsp_types::{Location, ReferenceParams}; + +use self::{ + entry::find_entry_references, label::find_label_references, string::find_string_references, +}; + +use super::{cursor::CursorContext, FeatureRequest}; + +pub fn find_all_references( + request: FeatureRequest<ReferenceParams>, + cancellation_token: &CancellationToken, +) -> Option<Vec<Location>> { + let mut references = Vec::new(); + let context = CursorContext::new(request); + log::debug!("[References] Cursor: {:?}", context.cursor); + find_label_references(&context, cancellation_token, &mut references); + find_entry_references(&context, cancellation_token, &mut references); + find_string_references(&context, cancellation_token, &mut references); + Some(references) +} diff --git a/support/texlab/src/features/reference/entry.rs b/support/texlab/src/features/reference/entry.rs new file mode 100644 index 0000000000..fbbcbdb4cc --- /dev/null +++ b/support/texlab/src/features/reference/entry.rs @@ -0,0 +1,198 @@ +use cancellation::CancellationToken; +use lsp_types::{Location, ReferenceParams}; + +use crate::{ + features::cursor::CursorContext, + syntax::{bibtex, latex, CstNode}, + DocumentData, LineIndexExt, +}; + +pub fn find_entry_references( + context: &CursorContext<ReferenceParams>, + cancellation_token: &CancellationToken, + references: &mut Vec<Location>, +) -> Option<()> { + let (key_text, _) = context + .find_citation_key_word() + .or_else(|| context.find_citation_key_command()) + .or_else(|| context.find_entry_key())?; + + for document in &context.request.subset.documents { + cancellation_token.result().ok()?; + + match &document.data { + DocumentData::Latex(data) => { + data.root + .descendants() + .filter_map(latex::Citation::cast) + .filter_map(|citation| citation.key_list()) + .flat_map(|keys| keys.words()) + .filter(|key| key.text() == key_text) + .map(|key| document.line_index.line_col_lsp_range(key.text_range())) + .for_each(|range| { + references.push(Location::new(document.uri.as_ref().clone().into(), range)); + }); + } + DocumentData::Bibtex(data) if context.request.params.context.include_declaration => { + data.root + .children() + .filter_map(bibtex::Entry::cast) + .filter_map(|entry| entry.key()) + .filter(|key| key.text() == key_text) + .map(|key| document.line_index.line_col_lsp_range(key.text_range())) + .for_each(|range| { + references.push(Location::new(document.uri.as_ref().clone().into(), range)); + }); + } + DocumentData::Bibtex(_) | DocumentData::BuildLog(_) => {} + } + } + Some(()) +} + +#[cfg(test)] +mod tests { + use lsp_types::Range; + + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .reference(); + + let mut actual_references = Vec::new(); + let context = CursorContext::new(request); + find_entry_references(&context, CancellationToken::none(), &mut actual_references); + + assert!(actual_references.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .reference(); + + let mut actual_references = Vec::new(); + let context = CursorContext::new(request); + find_entry_references(&context, CancellationToken::none(), &mut actual_references); + + assert!(actual_references.is_empty()); + } + + #[test] + fn test_definition() { + let tester = FeatureTester::builder() + .files(vec![ + ("foo.bib", r#"@article{foo,}"#), + ("bar.tex", r#"\cite{foo}\addbibresource{foo.bib}"#), + ]) + .main("foo.bib") + .line(0) + .character(11) + .build(); + let uri = tester.uri("bar.tex"); + let mut actual_references = Vec::new(); + + let request = tester.reference(); + let context = CursorContext::new(request); + find_entry_references(&context, CancellationToken::none(), &mut actual_references); + + let expected_references = vec![Location::new( + uri.as_ref().clone().into(), + Range::new_simple(0, 6, 0, 9), + )]; + assert_eq!(actual_references, expected_references); + } + + #[test] + fn test_definition_include_declaration() { + let tester = FeatureTester::builder() + .files(vec![ + ("foo.bib", r#"@article{foo,}"#), + ("bar.tex", r#"\cite{foo}\addbibresource{foo.bib}"#), + ]) + .main("foo.bib") + .line(0) + .character(11) + .include_declaration(true) + .build(); + let uri1 = tester.uri("foo.bib"); + let uri2 = tester.uri("bar.tex"); + let mut actual_references = Vec::new(); + + let request = tester.reference(); + let context = CursorContext::new(request); + find_entry_references(&context, CancellationToken::none(), &mut actual_references); + + let expected_references = vec![ + Location::new(uri1.as_ref().clone().into(), Range::new_simple(0, 9, 0, 12)), + Location::new(uri2.as_ref().clone().into(), Range::new_simple(0, 6, 0, 9)), + ]; + assert_eq!(actual_references, expected_references); + } + + #[test] + fn test_reference() { + let tester = FeatureTester::builder() + .files(vec![ + ("foo.bib", r#"@article{foo,}"#), + ("bar.tex", r#"\cite{foo}\addbibresource{foo.bib}"#), + ]) + .main("bar.tex") + .line(0) + .character(8) + .build(); + let uri = tester.uri("bar.tex"); + let mut actual_references = Vec::new(); + + let request = tester.reference(); + let context = CursorContext::new(request); + find_entry_references(&context, CancellationToken::none(), &mut actual_references); + + let expected_references = vec![Location::new( + uri.as_ref().clone().into(), + Range::new_simple(0, 6, 0, 9), + )]; + assert_eq!(actual_references, expected_references); + } + + #[test] + fn test_reference_include_declaration() { + let tester = FeatureTester::builder() + .files(vec![ + ("foo.bib", r#"@article{foo,}"#), + ("bar.tex", r#"\cite{foo}\addbibresource{foo.bib}"#), + ]) + .main("bar.tex") + .line(0) + .character(6) + .include_declaration(true) + .build(); + let uri1 = tester.uri("foo.bib"); + let uri2 = tester.uri("bar.tex"); + let mut actual_references = Vec::new(); + + let request = tester.reference(); + let context = CursorContext::new(request); + find_entry_references(&context, CancellationToken::none(), &mut actual_references); + + let expected_references = vec![ + Location::new(uri2.as_ref().clone().into(), Range::new_simple(0, 6, 0, 9)), + Location::new(uri1.as_ref().clone().into(), Range::new_simple(0, 9, 0, 12)), + ]; + assert_eq!(actual_references, expected_references); + } +} diff --git a/support/texlab/src/features/reference/label.rs b/support/texlab/src/features/reference/label.rs new file mode 100644 index 0000000000..e9aa3a8510 --- /dev/null +++ b/support/texlab/src/features/reference/label.rs @@ -0,0 +1,187 @@ +use cancellation::CancellationToken; +use lsp_types::{Location, ReferenceParams}; + +use crate::{features::cursor::CursorContext, LineIndexExt}; + +pub fn find_label_references( + context: &CursorContext<ReferenceParams>, + cancellation_token: &CancellationToken, + references: &mut Vec<Location>, +) -> Option<()> { + cancellation_token.result().ok()?; + + let (name_text, _) = context + .find_label_name_word() + .or_else(|| context.find_label_name_command())?; + + for document in &context.request.subset.documents { + if let Some(data) = document.data.as_latex() { + cancellation_token.result().ok()?; + for name in data + .extras + .label_names + .iter() + .filter(|name| name.text == name_text) + .filter(|name| { + !name.is_definition || context.request.params.context.include_declaration + }) + { + references.push(Location::new( + document.uri.as_ref().clone().into(), + document.line_index.line_col_lsp_range(name.range), + )); + } + } + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use lsp_types::Range; + + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_definition() { + let tester = FeatureTester::builder() + .files(vec![ + ("foo.tex", r#"\label{foo}"#), + ("bar.tex", r#"\ref{foo}\input{foo.tex}"#), + ]) + .main("foo.tex") + .line(0) + .character(8) + .build(); + let uri = tester.uri("bar.tex"); + let mut actual_references = Vec::new(); + + let request = tester.reference(); + let context = CursorContext::new(request); + find_label_references(&context, CancellationToken::none(), &mut actual_references); + + let expected_references = vec![Location::new( + uri.as_ref().clone().into(), + Range::new_simple(0, 5, 0, 8), + )]; + assert_eq!(actual_references, expected_references); + } + + #[test] + fn test_definition_include_declaration() { + let tester = FeatureTester::builder() + .files(vec![ + ("foo.tex", r#"\label{foo}\input{bar.tex}"#), + ("bar.tex", r#"\ref{foo}"#), + ]) + .main("foo.tex") + .line(0) + .character(9) + .include_declaration(true) + .build(); + let uri1 = tester.uri("foo.tex"); + let uri2 = tester.uri("bar.tex"); + let mut actual_references = Vec::new(); + + let request = tester.reference(); + let context = CursorContext::new(request); + find_label_references(&context, CancellationToken::none(), &mut actual_references); + + let expected_references = vec![ + Location::new(uri1.as_ref().clone().into(), Range::new_simple(0, 7, 0, 10)), + Location::new(uri2.as_ref().clone().into(), Range::new_simple(0, 5, 0, 8)), + ]; + assert_eq!(actual_references, expected_references); + } + + #[test] + fn test_reference() { + let tester = FeatureTester::builder() + .files(vec![ + ("foo.tex", r#"\label{foo}\input{bar.tex}"#), + ("bar.tex", r#"\ref{foo}"#), + ("baz.tex", r#"\ref{foo}\input{bar.tex}"#), + ]) + .main("bar.tex") + .line(0) + .character(7) + .build(); + let uri1 = tester.uri("bar.tex"); + let uri2 = tester.uri("baz.tex"); + let mut actual_references = Vec::new(); + + let request = tester.reference(); + let context = CursorContext::new(request); + find_label_references(&context, CancellationToken::none(), &mut actual_references); + + let expected_references = vec![ + Location::new(uri1.as_ref().clone().into(), Range::new_simple(0, 5, 0, 8)), + Location::new(uri2.as_ref().clone().into(), Range::new_simple(0, 5, 0, 8)), + ]; + assert_eq!(actual_references, expected_references); + } + + #[test] + fn test_reference_include_declaration() { + let tester = FeatureTester::builder() + .files(vec![ + ("foo.tex", r#"\label{foo}"#), + ("bar.tex", r#"\ref{foo}\input{foo.tex}"#), + ]) + .main("bar.tex") + .line(0) + .character(7) + .include_declaration(true) + .build(); + let uri1 = tester.uri("foo.tex"); + let uri2 = tester.uri("bar.tex"); + let mut actual_references = Vec::new(); + + let request = tester.reference(); + let context = CursorContext::new(request); + find_label_references(&context, CancellationToken::none(), &mut actual_references); + + let expected_references = vec![ + Location::new(uri2.as_ref().clone().into(), Range::new_simple(0, 5, 0, 8)), + Location::new(uri1.as_ref().clone().into(), Range::new_simple(0, 7, 0, 10)), + ]; + assert_eq!(actual_references, expected_references); + } + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .reference(); + let mut actual_references = Vec::new(); + + let context = CursorContext::new(request); + find_label_references(&context, CancellationToken::none(), &mut actual_references); + + assert!(actual_references.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .reference(); + let mut actual_references = Vec::new(); + + let context = CursorContext::new(request); + find_label_references(&context, CancellationToken::none(), &mut actual_references); + + assert!(actual_references.is_empty()); + } +} diff --git a/support/texlab/src/features/reference/string.rs b/support/texlab/src/features/reference/string.rs new file mode 100644 index 0000000000..b7dd4e039a --- /dev/null +++ b/support/texlab/src/features/reference/string.rs @@ -0,0 +1,206 @@ +use cancellation::CancellationToken; +use lsp_types::{Location, ReferenceParams}; + +use crate::{ + features::cursor::CursorContext, + syntax::{bibtex, CstNode}, + LineIndexExt, +}; + +pub fn find_string_references( + context: &CursorContext<ReferenceParams>, + cancellation_token: &CancellationToken, + items: &mut Vec<Location>, +) -> Option<()> { + let name_text = context + .cursor + .as_bibtex() + .filter(|token| token.kind() == bibtex::WORD) + .filter(|token| matches!(token.parent().kind(), bibtex::TOKEN | bibtex::STRING))? + .text(); + + let document = context.request.main_document(); + let data = document.data.as_bibtex()?; + for node in data.root.descendants() { + cancellation_token.result().ok()?; + if let Some(name) = bibtex::String::cast(node) + .and_then(|string| string.name()) + .filter(|name| { + context.request.params.context.include_declaration && name.text() == name_text + }) + .or_else(|| { + bibtex::Token::cast(node) + .and_then(|token| token.syntax().first_token()) + .filter(|name| name.text() == name_text) + }) + { + items.push(Location::new( + document.uri.as_ref().clone().into(), + document.line_index.line_col_lsp_range(name.text_range()), + )); + } + } + Some(()) +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + use lsp_types::Range; + + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_definition() { + let tester = FeatureTester::builder() + .files(vec![( + "main.bib", + indoc! { r#" + @string{foo = {Foo}} + @string{bar = {Bar}} + @article{baz, author = foo} + "# }, + )]) + .main("main.bib") + .line(2) + .character(24) + .build(); + let uri = tester.uri("main.bib"); + + let mut actual_references = Vec::new(); + let request = tester.reference(); + let context = CursorContext::new(request); + find_string_references(&context, CancellationToken::none(), &mut actual_references); + + let expected_references = vec![Location::new( + uri.as_ref().clone().into(), + Range::new_simple(2, 23, 2, 26), + )]; + assert_eq!(actual_references, expected_references); + } + + #[test] + fn test_definition_include_declaration() { + let tester = FeatureTester::builder() + .files(vec![( + "main.bib", + indoc! { r#" + @string{foo = {Foo}} + @string{bar = {Bar}} + @article{baz, author = foo} + "# }, + )]) + .main("main.bib") + .line(2) + .character(24) + .include_declaration(true) + .build(); + let uri = tester.uri("main.bib"); + + let mut actual_references = Vec::new(); + let request = tester.reference(); + let context = CursorContext::new(request); + find_string_references(&context, CancellationToken::none(), &mut actual_references); + + let expected_references = vec![ + Location::new(uri.as_ref().clone().into(), Range::new_simple(0, 8, 0, 11)), + Location::new(uri.as_ref().clone().into(), Range::new_simple(2, 23, 2, 26)), + ]; + assert_eq!(actual_references, expected_references); + } + + #[test] + fn test_reference() { + let tester = FeatureTester::builder() + .files(vec![( + "main.bib", + indoc! { r#" + @string{foo = {Foo}} + @string{bar = {Bar}} + @article{baz, author = foo} + "# }, + )]) + .main("main.bib") + .line(0) + .character(10) + .build(); + let uri = tester.uri("main.bib"); + + let mut actual_references = Vec::new(); + let request = tester.reference(); + let context = CursorContext::new(request); + find_string_references(&context, CancellationToken::none(), &mut actual_references); + + let expected_references = vec![Location::new( + uri.as_ref().clone().into(), + Range::new_simple(2, 23, 2, 26), + )]; + assert_eq!(actual_references, expected_references); + } + + #[test] + fn test_reference_include_declaration() { + let tester = FeatureTester::builder() + .files(vec![( + "main.bib", + indoc! { r#" + @string{foo = {Foo}} + @string{bar = {Bar}} + @article{baz, author = foo} + "# }, + )]) + .main("main.bib") + .line(0) + .character(10) + .include_declaration(true) + .build(); + let uri = tester.uri("main.bib"); + + let mut actual_references = Vec::new(); + let request = tester.reference(); + let context = CursorContext::new(request); + find_string_references(&context, CancellationToken::none(), &mut actual_references); + + let expected_references = vec![ + Location::new(uri.as_ref().clone().into(), Range::new_simple(0, 8, 0, 11)), + Location::new(uri.as_ref().clone().into(), Range::new_simple(2, 23, 2, 26)), + ]; + assert_eq!(actual_references, expected_references); + } + + #[test] + fn test_empty_latex_document() { + let request = FeatureTester::builder() + .files(vec![("main.tex", "")]) + .main("main.tex") + .line(0) + .character(0) + .build() + .reference(); + + let mut actual_references = Vec::new(); + let context = CursorContext::new(request); + find_string_references(&context, CancellationToken::none(), &mut actual_references); + + assert!(actual_references.is_empty()); + } + + #[test] + fn test_empty_bibtex_document() { + let request = FeatureTester::builder() + .files(vec![("main.bib", "")]) + .main("main.bib") + .line(0) + .character(0) + .build() + .reference(); + + let mut actual_references = Vec::new(); + let context = CursorContext::new(request); + find_string_references(&context, CancellationToken::none(), &mut actual_references); + + assert!(actual_references.is_empty()); + } +} diff --git a/support/texlab/src/features/rename.rs b/support/texlab/src/features/rename.rs new file mode 100644 index 0000000000..ba09fa268c --- /dev/null +++ b/support/texlab/src/features/rename.rs @@ -0,0 +1,34 @@ +mod command; +mod entry; +mod label; + +use cancellation::CancellationToken; +use lsp_types::{Range, RenameParams, TextDocumentPositionParams, WorkspaceEdit}; + +use self::{ + command::{prepare_command_rename, rename_command}, + entry::{prepare_entry_rename, rename_entry}, + label::{prepare_label_rename, rename_label}, +}; + +use super::{cursor::CursorContext, FeatureRequest}; + +pub fn prepare_rename_all( + request: FeatureRequest<TextDocumentPositionParams>, + cancellation_token: &CancellationToken, +) -> Option<Range> { + let context = CursorContext::new(request); + prepare_entry_rename(&context, cancellation_token) + .or_else(|| prepare_label_rename(&context, cancellation_token)) + .or_else(|| prepare_command_rename(&context, cancellation_token)) +} + +pub fn rename_all( + request: FeatureRequest<RenameParams>, + cancellation_token: &CancellationToken, +) -> Option<WorkspaceEdit> { + let context = CursorContext::new(request); + rename_entry(&context, cancellation_token) + .or_else(|| rename_label(&context, cancellation_token)) + .or_else(|| rename_command(&context, cancellation_token)) +} diff --git a/support/texlab/src/features/rename/command.rs b/support/texlab/src/features/rename/command.rs new file mode 100644 index 0000000000..402783f3ae --- /dev/null +++ b/support/texlab/src/features/rename/command.rs @@ -0,0 +1,98 @@ +use std::collections::HashMap; + +use cancellation::CancellationToken; +use cstree::{TextRange, TextSize}; +use lsp_types::{Range, RenameParams, TextEdit, WorkspaceEdit}; + +use crate::{ + features::cursor::{CursorContext, HasPosition}, + LineIndexExt, +}; + +pub fn prepare_command_rename<P: HasPosition>( + context: &CursorContext<P>, + _cancellation_token: &CancellationToken, +) -> Option<Range> { + Some( + context + .request + .main_document() + .line_index + .line_col_lsp_range(context.cursor.command_range(context.offset)?), + ) +} + +pub fn rename_command( + context: &CursorContext<RenameParams>, + cancellation_token: &CancellationToken, +) -> Option<WorkspaceEdit> { + cancellation_token.result().ok()?; + prepare_command_rename(context, cancellation_token)?; + let name = context.cursor.as_latex()?.text(); + let mut changes = HashMap::new(); + for document in &context.request.subset.documents { + cancellation_token.result().ok()?; + + if let Some(data) = document.data.as_latex() { + let edits = data + .root + .descendants_with_tokens() + .filter_map(|element| element.into_token()) + .filter(|token| token.kind().is_command_name() && token.text() == name) + .map(|token| { + let range = token.text_range(); + let range = document.line_index.line_col_lsp_range(TextRange::new( + range.start() + TextSize::from(1), + range.end(), + )); + TextEdit::new(range, context.request.params.new_name.clone()) + }) + .collect(); + + changes.insert(document.uri.as_ref().clone().into(), edits); + } + } + + Some(WorkspaceEdit::new(changes)) +} + +#[cfg(test)] +mod tests { + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_command() { + let tester = FeatureTester::builder() + .files(vec![ + ("foo.tex", r#"\baz\include{bar.tex}"#), + ("bar.tex", r#"\baz"#), + ]) + .main("foo.tex") + .line(0) + .character(2) + .new_name("qux") + .build(); + + let uri1 = tester.uri("foo.tex"); + let uri2 = tester.uri("bar.tex"); + let req = tester.rename(); + + let context = CursorContext::new(req); + let actual_edit = rename_command(&context, CancellationToken::none()).unwrap(); + + let mut expected_changes = HashMap::new(); + expected_changes.insert( + uri1.as_ref().clone().into(), + vec![TextEdit::new(Range::new_simple(0, 1, 0, 4), "qux".into())], + ); + expected_changes.insert( + uri2.as_ref().clone().into(), + vec![TextEdit::new(Range::new_simple(0, 1, 0, 4), "qux".into())], + ); + let expected_edit = WorkspaceEdit::new(expected_changes); + + assert_eq!(actual_edit, expected_edit); + } +} diff --git a/support/texlab/src/features/rename/entry.rs b/support/texlab/src/features/rename/entry.rs new file mode 100644 index 0000000000..14fbfd42d7 --- /dev/null +++ b/support/texlab/src/features/rename/entry.rs @@ -0,0 +1,161 @@ +use std::collections::HashMap; + +use cancellation::CancellationToken; +use lsp_types::{Range, RenameParams, TextEdit, WorkspaceEdit}; + +use crate::{ + features::cursor::{Cursor, CursorContext, HasPosition}, + syntax::{bibtex, latex, CstNode}, + DocumentData, LineIndexExt, +}; + +pub fn prepare_entry_rename<P: HasPosition>( + context: &CursorContext<P>, + _cancellation_token: &CancellationToken, +) -> Option<Range> { + let range = match &context.cursor { + Cursor::Latex(token) if token.kind() == latex::WORD => { + let group = latex::CurlyGroupWordList::cast(token.parent())?; + latex::Citation::cast(group.syntax().parent()?)?; + token.text_range() + } + Cursor::Bibtex(token) if token.kind() == bibtex::WORD => { + bibtex::Entry::cast(token.parent())?; + token.text_range() + } + _ => return None, + }; + + Some( + context + .request + .main_document() + .line_index + .line_col_lsp_range(range), + ) +} + +pub fn rename_entry( + context: &CursorContext<RenameParams>, + cancellation_token: &CancellationToken, +) -> Option<WorkspaceEdit> { + cancellation_token.result().ok()?; + prepare_entry_rename(context, cancellation_token)?; + let key_text = context + .cursor + .as_latex() + .map(|token| token.text()) + .or_else(|| context.cursor.as_bibtex().map(|token| token.text()))?; + + let mut changes = HashMap::new(); + for document in &context.request.subset.documents { + cancellation_token.result().ok()?; + match &document.data { + DocumentData::Latex(data) => { + let edits: Vec<_> = data + .root + .descendants() + .filter_map(latex::Citation::cast) + .filter_map(|citation| citation.key_list()) + .flat_map(|keys| keys.words()) + .filter(|key| key.text() == key_text) + .map(|key| document.line_index.line_col_lsp_range(key.text_range())) + .map(|range| TextEdit::new(range, context.request.params.new_name.clone())) + .collect(); + changes.insert(document.uri.as_ref().clone().into(), edits); + } + DocumentData::Bibtex(data) => { + let edits: Vec<_> = data + .root + .descendants() + .filter_map(bibtex::Entry::cast) + .filter_map(|entry| entry.key()) + .filter(|key| key.text() == key_text) + .map(|key| document.line_index.line_col_lsp_range(key.text_range())) + .map(|range| TextEdit::new(range, context.request.params.new_name.clone())) + .collect(); + changes.insert(document.uri.as_ref().clone().into(), edits); + } + DocumentData::BuildLog(_) => {} + } + } + + Some(WorkspaceEdit::new(changes)) +} + +#[cfg(test)] +mod tests { + use lsp_types::TextEdit; + + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_entry() { + let tester = FeatureTester::builder() + .files(vec![ + ("main.bib", r#"@article{foo, bar = baz}"#), + ("main.tex", "\\addbibresource{main.bib}\n\\cite{foo}"), + ]) + .main("main.bib") + .line(0) + .character(9) + .new_name("qux") + .build(); + + let uri1 = tester.uri("main.bib"); + let uri2 = tester.uri("main.tex"); + let request = tester.rename(); + + let context = CursorContext::new(request); + let actual_edit = rename_entry(&context, CancellationToken::none()).unwrap(); + + let mut expected_changes = HashMap::new(); + expected_changes.insert( + uri1.as_ref().clone().into(), + vec![TextEdit::new(Range::new_simple(0, 9, 0, 12), "qux".into())], + ); + expected_changes.insert( + uri2.as_ref().clone().into(), + vec![TextEdit::new(Range::new_simple(1, 6, 1, 9), "qux".into())], + ); + let expected_edit = WorkspaceEdit::new(expected_changes); + + assert_eq!(actual_edit, expected_edit); + } + + #[test] + fn test_citation() { + let tester = FeatureTester::builder() + .files(vec![ + ("main.bib", r#"@article{foo, bar = baz}"#), + ("main.tex", "\\addbibresource{main.bib}\n\\cite{foo}"), + ]) + .main("main.tex") + .line(1) + .character(6) + .new_name("qux") + .build(); + + let uri1 = tester.uri("main.bib"); + let uri2 = tester.uri("main.tex"); + let request = tester.rename(); + + let context = CursorContext::new(request); + let actual_edit = rename_entry(&context, CancellationToken::none()).unwrap(); + + let mut expected_changes = HashMap::new(); + expected_changes.insert( + uri1.as_ref().clone().into(), + vec![TextEdit::new(Range::new_simple(0, 9, 0, 12), "qux".into())], + ); + expected_changes.insert( + uri2.as_ref().clone().into(), + vec![TextEdit::new(Range::new_simple(1, 6, 1, 9), "qux".into())], + ); + let expected_edit = WorkspaceEdit::new(expected_changes); + + assert_eq!(actual_edit, expected_edit); + } +} diff --git a/support/texlab/src/features/rename/label.rs b/support/texlab/src/features/rename/label.rs new file mode 100644 index 0000000000..11b414ccfa --- /dev/null +++ b/support/texlab/src/features/rename/label.rs @@ -0,0 +1,142 @@ +use std::collections::HashMap; + +use cancellation::CancellationToken; +use lsp_types::{Range, RenameParams, TextEdit, WorkspaceEdit}; + +use crate::{ + features::cursor::{CursorContext, HasPosition}, + syntax::{latex, CstNode}, + LineIndexExt, +}; + +pub fn prepare_label_rename<P: HasPosition>( + context: &CursorContext<P>, + _cancellation_token: &CancellationToken, +) -> Option<Range> { + let name = context.cursor.as_latex()?; + name.parent().parent().filter(|node| { + matches!( + node.kind(), + latex::LABEL_DEFINITION | latex::LABEL_REFERENCE | latex::LABEL_REFERENCE_RANGE + ) + })?; + + Some( + context + .request + .main_document() + .line_index + .line_col_lsp_range(name.text_range()), + ) +} + +pub fn rename_label( + context: &CursorContext<RenameParams>, + cancellation_token: &CancellationToken, +) -> Option<WorkspaceEdit> { + prepare_label_rename(context, cancellation_token)?; + let name_text = context.cursor.as_latex()?.text(); + let mut changes = HashMap::new(); + for document in &context.request.subset.documents { + cancellation_token.result().ok()?; + if let Some(data) = document.data.as_latex() { + let mut edits = Vec::new(); + for node in data.root.descendants() { + if let Some(range) = latex::LabelDefinition::cast(node) + .and_then(|label| label.name()) + .and_then(|name| name.word()) + .filter(|name| name.text() == name_text) + .map(|name| document.line_index.line_col_lsp_range(name.text_range())) + { + edits.push(TextEdit::new( + range, + context.request.params.new_name.clone(), + )); + } + + latex::LabelReference::cast(node) + .and_then(|label| label.name_list()) + .into_iter() + .flat_map(|label| label.words()) + .filter(|name| name.text() == name_text) + .map(|name| document.line_index.line_col_lsp_range(name.text_range())) + .for_each(|range| { + edits.push(TextEdit::new( + range, + context.request.params.new_name.clone(), + )); + }); + + if let Some(label) = latex::LabelReferenceRange::cast(node) { + if let Some(name1) = label + .from() + .and_then(|name| name.word()) + .filter(|name| name.text() == name_text) + { + edits.push(TextEdit::new( + document.line_index.line_col_lsp_range(name1.text_range()), + context.request.params.new_name.clone(), + )); + } + + if let Some(name2) = label + .from() + .and_then(|name| name.word()) + .filter(|name| name.text() == name_text) + { + edits.push(TextEdit::new( + document.line_index.line_col_lsp_range(name2.text_range()), + context.request.params.new_name.clone(), + )); + } + } + } + + changes.insert(document.uri.as_ref().clone().into(), edits); + } + } + + Some(WorkspaceEdit::new(changes)) +} + +#[cfg(test)] +mod tests { + use crate::{features::testing::FeatureTester, RangeExt}; + + use super::*; + + #[test] + fn test_label() { + let tester = FeatureTester::builder() + .files(vec![ + ("foo.tex", r#"\label{foo}\include{bar}"#), + ("bar.tex", r#"\ref{foo}"#), + ("baz.tex", r#"\ref{foo}"#), + ]) + .main("foo.tex") + .line(0) + .character(7) + .new_name("bar") + .build(); + + let uri1 = tester.uri("foo.tex"); + let uri2 = tester.uri("bar.tex"); + let request = tester.rename(); + + let context = CursorContext::new(request); + let actual_edit = rename_label(&context, CancellationToken::none()).unwrap(); + + let mut expected_changes = HashMap::new(); + expected_changes.insert( + uri1.as_ref().clone().into(), + vec![TextEdit::new(Range::new_simple(0, 7, 0, 10), "bar".into())], + ); + expected_changes.insert( + uri2.as_ref().clone().into(), + vec![TextEdit::new(Range::new_simple(0, 5, 0, 8), "bar".into())], + ); + let expected_edit = WorkspaceEdit::new(expected_changes); + + assert_eq!(actual_edit, expected_edit); + } +} diff --git a/support/texlab/src/features/semantic.rs b/support/texlab/src/features/semantic.rs new file mode 100644 index 0000000000..8dd2674601 --- /dev/null +++ b/support/texlab/src/features/semantic.rs @@ -0,0 +1,21 @@ +mod bibtex; +pub mod legend; + +use cancellation::CancellationToken; +use lsp_types::{SemanticTokens, SemanticTokensRangeParams}; + +use self::bibtex::find_bibtex_semantic_tokens_range; + +use super::FeatureRequest; + +pub fn find_semantic_tokens_range( + request: FeatureRequest<SemanticTokensRangeParams>, + cancellation_token: &CancellationToken, +) -> Option<SemanticTokens> { + let mut data = Vec::new(); + find_bibtex_semantic_tokens_range(&request, &mut data, cancellation_token); + Some(SemanticTokens { + result_id: None, + data, + }) +} diff --git a/support/texlab/src/features/semantic/bibtex.rs b/support/texlab/src/features/semantic/bibtex.rs new file mode 100644 index 0000000000..6d6a46b6ce --- /dev/null +++ b/support/texlab/src/features/semantic/bibtex.rs @@ -0,0 +1,289 @@ +use cancellation::CancellationToken; +use cstree::{NodeOrToken, TextRange}; +use lsp_types::{ + SemanticToken, SemanticTokenModifier, SemanticTokenType, SemanticTokensRangeParams, +}; + +use crate::{ + features::FeatureRequest, + syntax::{bibtex, CstNode}, + BibtexEntryTypeCategory, LineIndex, LineIndexExt, LANGUAGE_DATA, +}; + +use super::legend::*; + +struct Context<'a> { + line_index: &'a LineIndex, + previous_line: u32, + previous_character: u32, + tokens: &'a mut Vec<SemanticToken>, + cancellation_token: &'a CancellationToken, +} + +impl<'a> Context<'a> { + pub fn insert( + &mut self, + range: TextRange, + token_type: SemanticTokenType, + token_modifiers_bitset: ModifierSet, + ) { + let range = self.line_index.line_col_lsp_range(range); + + let mut delta_line = range.start.line; + let mut delta_start = range.start.character; + if !self.tokens.is_empty() { + delta_line -= self.previous_line; + if delta_line == 0 { + delta_start -= self.previous_character; + } + } + self.tokens.push(SemanticToken { + delta_line, + delta_start, + length: range.end.character - range.start.character, + token_type: type_index(token_type), + token_modifiers_bitset: token_modifiers_bitset.0, + }); + + self.previous_line = range.start.line; + self.previous_character = range.start.character; + } +} + +pub fn find_bibtex_semantic_tokens_range( + request: &FeatureRequest<SemanticTokensRangeParams>, + tokens: &mut Vec<SemanticToken>, + cancellation_token: &CancellationToken, +) -> Option<()> { + let document = request.main_document(); + let data = document.data.as_bibtex()?; + + let mut context = Context { + line_index: &document.line_index, + previous_line: 0, + previous_character: 0, + tokens, + cancellation_token, + }; + + let range = document.line_index.offset_lsp_range(request.params.range); + + for node in data + .root + .children() + .filter(|node| node.text_range().intersect(range).is_some()) + { + cancellation_token.result().ok()?; + visit_junk(&mut context, node) + .or_else(|| visit_preamble(&mut context, node)) + .or_else(|| visit_string(&mut context, node)) + .or_else(|| visit_entry(&mut context, node)); + } + + Some(()) +} + +fn visit_junk(context: &mut Context, node: &bibtex::SyntaxNode) -> Option<()> { + let junk = bibtex::Junk::cast(node)?; + for token in junk + .syntax() + .children_with_tokens() + .filter_map(|element| element.into_token()) + .filter(|token| token.kind() != bibtex::WHITESPACE) + { + context.insert(token.text_range(), JUNK, ModifierSet::default()); + } + Some(()) +} + +fn visit_preamble(context: &mut Context, node: &bibtex::SyntaxNode) -> Option<()> { + let preamble = bibtex::Preamble::cast(node)?; + for element in preamble.syntax().children_with_tokens() { + match element { + NodeOrToken::Node(value) => { + visit_value(context, value); + } + NodeOrToken::Token(token) => match token.kind() { + bibtex::PREAMBLE_TYPE => { + context.insert(token.text_range(), PREAMBLE_TYPE, ModifierSet::default()); + } + bibtex::L_CURLY | bibtex::R_CURLY => { + context.insert(token.text_range(), CURLY, ModifierSet::default()); + } + bibtex::L_PAREN | bibtex::R_PAREN => { + context.insert(token.text_range(), PAREN, ModifierSet::default()); + } + _ => {} + }, + } + } + + Some(()) +} + +fn visit_string(context: &mut Context, node: &bibtex::SyntaxNode) -> Option<()> { + let string = bibtex::String::cast(node)?; + for element in string.syntax().children_with_tokens() { + match element { + NodeOrToken::Node(value) => { + visit_value(context, value); + } + NodeOrToken::Token(token) => match token.kind() { + bibtex::STRING_TYPE => { + context.insert(token.text_range(), STRING_TYPE, ModifierSet::default()); + } + bibtex::L_CURLY | bibtex::R_CURLY => { + context.insert(token.text_range(), CURLY, ModifierSet::default()); + } + bibtex::L_PAREN | bibtex::R_PAREN => { + context.insert(token.text_range(), PAREN, ModifierSet::default()); + } + bibtex::WORD => { + context.insert( + token.text_range(), + STRING_NAME, + ModifierSet::default() | SemanticTokenModifier::DEFINITION, + ); + } + bibtex::EQUALITY_SIGN => { + context.insert(token.text_range(), EQUALITY_SIGN, ModifierSet::default()); + } + _ => {} + }, + } + } + + Some(()) +} + +fn visit_entry(context: &mut Context, node: &bibtex::SyntaxNode) -> Option<()> { + let entry = bibtex::Entry::cast(node)?; + + for element in entry.syntax().children_with_tokens() { + match element { + NodeOrToken::Node(field) => { + visit_field(context, field); + } + NodeOrToken::Token(token) => match token.kind() { + bibtex::ENTRY_TYPE => { + context.insert( + token.text_range(), + match LANGUAGE_DATA.find_entry_type(&token.text()[1..]) { + Some(ty) => match ty.category { + BibtexEntryTypeCategory::Article => ARTICLE_TYPE, + BibtexEntryTypeCategory::Misc => MISC_TYPE, + BibtexEntryTypeCategory::String => STRING_TYPE, + BibtexEntryTypeCategory::Book => BOOK_TYPE, + BibtexEntryTypeCategory::Collection => COLLECTION_TYPE, + BibtexEntryTypeCategory::Part => PART_TYPE, + BibtexEntryTypeCategory::Thesis => THESIS_TYPE, + }, + None => UNKNOWN_TYPE, + }, + ModifierSet::default(), + ); + } + bibtex::L_CURLY | bibtex::R_CURLY => { + context.insert(token.text_range(), CURLY, ModifierSet::default()); + } + bibtex::L_PAREN | bibtex::R_PAREN => { + context.insert(token.text_range(), PAREN, ModifierSet::default()); + } + bibtex::WORD => { + context.insert( + token.text_range(), + ENTRY_KEY, + ModifierSet::default() + | SemanticTokenModifier::DEFINITION + | SemanticTokenModifier::READONLY, + ); + } + bibtex::COMMA => { + context.insert(token.text_range(), COMMA, ModifierSet::default()); + } + _ => {} + }, + } + } + + Some(()) +} + +fn visit_field(context: &mut Context, node: &bibtex::SyntaxNode) -> Option<()> { + context.cancellation_token.result().ok()?; + let field = bibtex::Field::cast(node)?; + context.insert(field.name()?.text_range(), FIELD, ModifierSet::default()); + if let Some(token) = field.equality_sign() { + context.insert(token.text_range(), EQUALITY_SIGN, ModifierSet::default()); + } + + if let Some(value) = field.value() { + visit_value(context, value.syntax()); + } + Some(()) +} + +fn visit_value(context: &mut Context, node: &bibtex::SyntaxNode) -> Option<()> { + context.cancellation_token.result().ok()?; + let value = bibtex::Value::cast(node)?; + for child in value.syntax().children_with_tokens() { + match child { + NodeOrToken::Token(token) if token.kind() == bibtex::HASH => { + context.insert(token.text_range(), HASH, ModifierSet::default()); + } + NodeOrToken::Token(_) => {} + NodeOrToken::Node(token) => { + if let Some(word) = token + .first_child_or_token() + .and_then(|child| child.into_token()) + { + if word.text().chars().all(|c| c.is_ascii_digit()) { + context.insert( + word.text_range(), + SemanticTokenType::NUMBER, + ModifierSet::default(), + ); + } else { + context.insert(word.text_range(), STRING_NAME, ModifierSet::default()); + } + } else { + visit_content(context, token); + } + } + } + } + Some(()) +} + +fn visit_content(context: &mut Context, node: &bibtex::SyntaxNode) -> Option<()> { + for token in node + .descendants_with_tokens() + .filter_map(|element| element.into_token()) + { + match token.kind() { + bibtex::WORD => { + context.insert(token.text_range(), TEXT, ModifierSet::default()); + } + bibtex::COMMA => { + context.insert(token.text_range(), COMMA, ModifierSet::default()); + } + bibtex::L_CURLY | bibtex::R_CURLY => { + context.insert(token.text_range(), CURLY, ModifierSet::default()); + } + bibtex::L_PAREN | bibtex::R_PAREN => { + context.insert(token.text_range(), PAREN, ModifierSet::default()); + } + bibtex::SyntaxKind::QUOTE => { + context.insert(token.text_range(), QUOTE, ModifierSet::default()); + } + bibtex::SyntaxKind::EQUALITY_SIGN => { + context.insert(token.text_range(), EQUALITY_SIGN, ModifierSet::default()); + } + bibtex::SyntaxKind::COMMAND_NAME => { + context.insert(token.text_range(), GENERIC_COMMAND, ModifierSet::default()); + } + _ => {} + } + } + Some(()) +} diff --git a/support/texlab/src/features/semantic/legend.rs b/support/texlab/src/features/semantic/legend.rs new file mode 100644 index 0000000000..0b2a53cd38 --- /dev/null +++ b/support/texlab/src/features/semantic/legend.rs @@ -0,0 +1,83 @@ +use std::ops; + +use lsp_types::{SemanticTokenModifier, SemanticTokenType}; + +macro_rules! define_semantic_token_types { + ($(($ident:ident, $string:literal)),*$(,)?) => { + $(pub const $ident: SemanticTokenType = SemanticTokenType::new($string);)* + + pub const SUPPORTED_TYPES: &[SemanticTokenType] = &[ + SemanticTokenType::COMMENT, + SemanticTokenType::NUMBER, + $($ident),* + ]; + }; +} + +define_semantic_token_types![ + (JUNK, "junk"), + (PREAMBLE_TYPE, "preambleType"), + (STRING_TYPE, "stringType"), + (COMMENT_TYPE, "commentType"), + (ARTICLE_TYPE, "articleType"), + (BOOK_TYPE, "bookType"), + (COLLECTION_TYPE, "collectionType"), + (PART_TYPE, "partType"), + (THESIS_TYPE, "thesisType"), + (MISC_TYPE, "miscType"), + (UNKNOWN_TYPE, "unknownType"), + (CURLY, "curly"), + (BRACK, "brack"), + (PAREN, "paren"), + (COMMA, "comma"), + (ENTRY_KEY, "entryKey"), + (FIELD, "field"), + (TEXT, "text"), + (STRING_NAME, "string"), + (EQUALITY_SIGN, "equalitySign"), + (HASH, "hash"), + (QUOTE, "quote"), + (GENERIC_COMMAND, "genericCommand"), +]; + +macro_rules! define_semantic_token_modifiers { + ($(($ident:ident, $string:literal)),*$(,)?) => { + $(pub const $ident: SemanticTokenModifier = SemanticTokenModifier::new($string);)* + + pub const SUPPORTED_MODIFIERS: &[SemanticTokenModifier] = &[ + SemanticTokenModifier::DEFAULT_LIBRARY, + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::DEFINITION, + SemanticTokenModifier::READONLY, + $($ident),* + ]; + }; +} + +define_semantic_token_modifiers![(ITALIC, "italic"),]; + +pub fn type_index(ty: SemanticTokenType) -> u32 { + SUPPORTED_TYPES.iter().position(|t| *t == ty).unwrap() as u32 +} + +#[derive(Default)] +pub struct ModifierSet(pub u32); + +impl ops::BitOrAssign<SemanticTokenModifier> for ModifierSet { + fn bitor_assign(&mut self, rhs: SemanticTokenModifier) { + let index = SUPPORTED_MODIFIERS + .iter() + .position(|modifier| modifier == &rhs) + .unwrap(); + self.0 |= 1 << index; + } +} + +impl ops::BitOr<SemanticTokenModifier> for ModifierSet { + type Output = ModifierSet; + + fn bitor(mut self, rhs: SemanticTokenModifier) -> Self::Output { + self |= rhs; + self + } +} diff --git a/support/texlab/src/features/symbol.rs b/support/texlab/src/features/symbol.rs new file mode 100644 index 0000000000..349b6d0a37 --- /dev/null +++ b/support/texlab/src/features/symbol.rs @@ -0,0 +1,125 @@ +mod bibtex; +mod latex; +mod project_order; +mod types; + +use std::{cmp::Reverse, sync::Arc}; + +use cancellation::CancellationToken; +use lsp_types::{ + DocumentSymbolParams, DocumentSymbolResponse, SymbolInformation, WorkspaceSymbolParams, +}; + +use crate::{ClientCapabilitiesExt, Uri, Workspace}; + +use self::{ + bibtex::find_bibtex_symbols, latex::find_latex_symbols, project_order::ProjectOrdering, +}; + +use super::FeatureRequest; + +pub fn find_document_symbols( + req: FeatureRequest<DocumentSymbolParams>, + token: &CancellationToken, +) -> DocumentSymbolResponse { + let mut buf = Vec::new(); + find_latex_symbols(&req.subset, &mut buf, token); + find_bibtex_symbols(&req.subset, &mut buf, token); + if req + .context + .client_capabilities + .lock() + .unwrap() + .has_hierarchical_document_symbol_support() + { + DocumentSymbolResponse::Nested( + buf.into_iter() + .map(|symbol| symbol.into_document_symbol()) + .collect(), + ) + } else { + let mut new_buf = Vec::new(); + for symbol in buf { + symbol.flatten(&mut new_buf); + } + let mut new_buf = new_buf + .into_iter() + .map(|symbol| symbol.into_symbol_info(req.main_document().uri.as_ref().clone())) + .collect(); + sort_symbols(req.workspace.as_ref(), &mut new_buf); + DocumentSymbolResponse::Flat(new_buf) + } +} + +#[derive(Debug, Clone)] +struct WorkspaceSymbol { + info: SymbolInformation, + search_text: String, +} + +pub fn find_workspace_symbols( + workspace: &dyn Workspace, + params: &WorkspaceSymbolParams, + token: &CancellationToken, +) -> Vec<SymbolInformation> { + let mut symbols = Vec::new(); + + for document in workspace.documents() { + if let Some(subset) = workspace.subset(Arc::clone(&document.uri)) { + let mut buf = Vec::new(); + find_latex_symbols(&subset, &mut buf, token); + find_bibtex_symbols(&subset, &mut buf, token); + let mut new_buf = Vec::new(); + + for symbol in buf { + symbol.flatten(&mut new_buf); + } + + for symbol in new_buf { + symbols.push(WorkspaceSymbol { + search_text: symbol.search_text(), + info: symbol.into_symbol_info(document.uri.as_ref().clone()), + }); + } + } + } + + let query_words: Vec<_> = params + .query + .split_whitespace() + .map(str::to_lowercase) + .collect(); + let mut filtered = Vec::new(); + for symbol in symbols { + let mut included = true; + for word in &query_words { + if !symbol.search_text.contains(word) { + included = false; + break; + } + } + + if included { + filtered.push(symbol.info); + } + } + sort_symbols(workspace, &mut filtered); + filtered +} + +fn sort_symbols(workspace: &dyn Workspace, symbols: &mut Vec<SymbolInformation>) { + let ordering = ProjectOrdering::from(workspace); + symbols.sort_by(|left, right| { + let left_key = ( + ordering.get(&Uri::from(left.location.uri.clone())), + left.location.range.start, + Reverse(left.location.range.end), + ); + let right_key = ( + ordering.get(&Uri::from(right.location.uri.clone())), + right.location.range.start, + Reverse(right.location.range.end), + ); + left_key.cmp(&right_key) + }); +} diff --git a/support/texlab/src/features/symbol/bibtex.rs b/support/texlab/src/features/symbol/bibtex.rs new file mode 100644 index 0000000000..e030195880 --- /dev/null +++ b/support/texlab/src/features/symbol/bibtex.rs @@ -0,0 +1,88 @@ +use cancellation::CancellationToken; + +use crate::{ + syntax::{ + bibtex::{self, HasType}, + CstNode, + }, + BibtexEntryTypeCategory, LineIndexExt, WorkspaceSubset, LANGUAGE_DATA, +}; + +use super::types::{InternalSymbol, InternalSymbolKind}; + +pub fn find_bibtex_symbols( + subset: &WorkspaceSubset, + buf: &mut Vec<InternalSymbol>, + token: &CancellationToken, +) -> Option<()> { + let main_document = subset.documents.first()?; + let data = main_document.data.as_bibtex()?; + + for node in data.root.children() { + if token.is_canceled() { + return None; + } + + if let Some(string) = bibtex::String::cast(node) { + if let Some(name) = string.name() { + buf.push(InternalSymbol { + name: name.text().into(), + label: None, + kind: InternalSymbolKind::String, + deprecated: false, + full_range: main_document + .line_index + .line_col_lsp_range(string.small_range()), + selection_range: main_document + .line_index + .line_col_lsp_range(name.text_range()), + children: Vec::new(), + }) + } + } else if let Some(entry) = bibtex::Entry::cast(node) { + if let Some(ty) = entry.ty() { + if let Some(key) = entry.key() { + let mut children = Vec::new(); + for field in entry.fields() { + if let Some(name) = field.name() { + let symbol = InternalSymbol { + name: name.text().to_string(), + label: None, + kind: InternalSymbolKind::Field, + deprecated: false, + full_range: main_document + .line_index + .line_col_lsp_range(field.small_range()), + selection_range: main_document + .line_index + .line_col_lsp_range(name.text_range()), + children: Vec::new(), + }; + children.push(symbol); + } + } + + let category = LANGUAGE_DATA + .find_entry_type(&ty.text()[1..]) + .map(|ty| ty.category) + .unwrap_or(BibtexEntryTypeCategory::Misc); + + buf.push(InternalSymbol { + name: key.text().to_string(), + label: None, + kind: InternalSymbolKind::Entry(category), + deprecated: false, + full_range: main_document + .line_index + .line_col_lsp_range(entry.small_range()), + selection_range: main_document + .line_index + .line_col_lsp_range(key.text_range()), + children, + }); + } + } + } + } + Some(()) +} diff --git a/support/texlab/src/features/symbol/latex.rs b/support/texlab/src/features/symbol/latex.rs new file mode 100644 index 0000000000..ce584c0c76 --- /dev/null +++ b/support/texlab/src/features/symbol/latex.rs @@ -0,0 +1,488 @@ +use std::str::FromStr; + +use cancellation::CancellationToken; +use lsp_types::Range; +use smol_str::SmolStr; +use titlecase::titlecase; + +use crate::{ + find_caption_by_parent, find_label_number, + syntax::{ + latex::{self, HasBrack, HasCurly}, + CstNode, + }, + LabelledFloatKind, LatexDocumentData, LineIndexExt, WorkspaceSubset, LANGUAGE_DATA, +}; + +use super::types::{InternalSymbol, InternalSymbolKind}; + +pub fn find_latex_symbols( + subset: &WorkspaceSubset, + buf: &mut Vec<InternalSymbol>, + token: &CancellationToken, +) -> Option<()> { + let main_document = subset + .documents + .first() + .filter(|document| document.uri.as_str().ends_with(".tex"))?; + + let data = main_document.data.as_latex()?; + let mut context = Context { + subset, + data, + token, + }; + + let root = &context.data.root; + let mut symbols = visit(&mut context, root); + buf.append(&mut symbols); + Some(()) +} + +struct Context<'a> { + subset: &'a WorkspaceSubset, + data: &'a LatexDocumentData, + token: &'a CancellationToken, +} + +fn visit(context: &mut Context, node: &latex::SyntaxNode) -> Vec<InternalSymbol> { + let symbol = match node.kind() { + latex::PART + | latex::CHAPTER + | latex::SECTION + | latex::SUBSECTION + | latex::SUBSUBSECTION + | latex::PARAGRAPH + | latex::SUBPARAGRAPH => visit_section(context, node), + latex::ENUM_ITEM => visit_enum_item(context, node), + latex::EQUATION => visit_equation(context, node), + latex::ENVIRONMENT => latex::Environment::cast(node) + .and_then(|env| env.begin()) + .and_then(|begin| begin.name()) + .and_then(|name| name.word()) + .map(|name| name.text()) + .and_then(|name| { + if LANGUAGE_DATA + .math_environments + .iter() + .any(|env| env == name) + { + visit_equation_environment(context, node) + } else if LANGUAGE_DATA + .enum_environments + .iter() + .any(|env| env == name) + { + visit_enumeration(context, node, name) + } else if let Ok(float_kind) = LabelledFloatKind::from_str(name) { + visit_float(context, node, float_kind) + } else { + visit_theorem(context, node, name) + } + }), + _ => None, + }; + + match symbol { + Some(mut parent) => { + for child in node.children() { + if context.token.is_canceled() { + break; + } + + parent.children.append(&mut visit(context, child)); + } + vec![parent] + } + None => { + let mut symbols = Vec::new(); + for child in node.children() { + if context.token.is_canceled() { + break; + } + symbols.append(&mut visit(context, child)); + } + symbols + } + } +} + +fn visit_section(context: &mut Context, node: &latex::SyntaxNode) -> Option<InternalSymbol> { + let section = latex::Section::cast(node)?; + let full_range = context + .subset + .documents + .first()? + .line_index + .line_col_lsp_range(section.small_range()); + + let group = section.name()?; + let group_text = group.content_text()?; + + let symbol = match find_label_by_parent(context, section.syntax()) { + Some(NumberedLabel { + name: label, + range: selection_range, + number, + }) => { + let name = match number { + Some(number) => format!("{} {}", number, group_text), + None => group_text.to_string(), + }; + + InternalSymbol { + name, + label: Some(label), + kind: InternalSymbolKind::Section, + deprecated: false, + full_range, + selection_range, + children: Vec::new(), + } + } + None => InternalSymbol { + name: group_text.to_string(), + label: None, + kind: InternalSymbolKind::Section, + deprecated: false, + full_range, + selection_range: full_range, + children: Vec::new(), + }, + }; + Some(symbol) +} + +fn visit_enum_item(context: &mut Context, node: &latex::SyntaxNode) -> Option<InternalSymbol> { + let enum_item = latex::EnumItem::cast(node)?; + if !enum_item + .syntax() + .ancestors() + .filter_map(latex::Environment::cast) + .filter_map(|environment| environment.begin()) + .filter_map(|begin| begin.name()) + .filter_map(|name| name.word()) + .any(|name| { + LANGUAGE_DATA + .enum_environments + .iter() + .any(|e| e == name.text()) + }) + { + return None; + } + + let full_range = context + .subset + .documents + .first()? + .line_index + .line_col_lsp_range(enum_item.small_range()); + + let name = enum_item + .label() + .and_then(|label| label.content_text()) + .unwrap_or_else(|| "Item".to_string()); + + let symbol = match find_label_by_parent(context, node) { + Some(NumberedLabel { + name: label, + range: selection_range, + number, + }) => InternalSymbol { + name: number.map(Into::into).unwrap_or_else(|| name.to_string()), + label: Some(label), + kind: InternalSymbolKind::EnumerationItem, + deprecated: false, + full_range, + selection_range, + children: Vec::new(), + }, + None => InternalSymbol { + name: name.to_string(), + label: None, + kind: InternalSymbolKind::EnumerationItem, + deprecated: false, + full_range, + selection_range: full_range, + children: Vec::new(), + }, + }; + Some(symbol) +} + +fn visit_equation(context: &mut Context, node: &latex::SyntaxNode) -> Option<InternalSymbol> { + let equation = latex::Equation::cast(node)?; + + let full_range = context + .subset + .documents + .first()? + .line_index + .line_col_lsp_range(equation.small_range()); + + make_equation_symbol(context, node, full_range) +} + +fn visit_equation_environment( + context: &mut Context, + node: &latex::SyntaxNode, +) -> Option<InternalSymbol> { + let environment = latex::Environment::cast(node)?; + + let full_range = context + .subset + .documents + .first()? + .line_index + .line_col_lsp_range(environment.small_range()); + + make_equation_symbol(context, node, full_range) +} + +fn make_equation_symbol( + context: &mut Context, + node: &cstree::ResolvedNode<latex::Language>, + full_range: Range, +) -> Option<InternalSymbol> { + let symbol = match find_label_by_parent(context, node) { + Some(NumberedLabel { + name: label, + range: selection_range, + number, + }) => { + let name = match number { + Some(number) => format!("Equation ({})", number), + None => "Equation".to_string(), + }; + + InternalSymbol { + name, + label: Some(label), + kind: InternalSymbolKind::Equation, + deprecated: false, + full_range, + selection_range, + children: Vec::new(), + } + } + None => InternalSymbol { + name: "Equation".to_string(), + label: None, + kind: InternalSymbolKind::Equation, + deprecated: false, + full_range, + selection_range: full_range, + children: Vec::new(), + }, + }; + Some(symbol) +} + +fn visit_enumeration( + context: &mut Context, + node: &latex::SyntaxNode, + env_name: &str, +) -> Option<InternalSymbol> { + let environment = latex::Environment::cast(node)?; + let full_range = context + .subset + .documents + .first()? + .line_index + .line_col_lsp_range(environment.small_range()); + + let name = titlecase(env_name); + let symbol = match find_label_by_parent(context, environment.syntax()) { + Some(NumberedLabel { + name: label, + range: selection_range, + number, + }) => { + let name = match number { + Some(number) => format!("{} {}", name, number), + None => name, + }; + + InternalSymbol { + name, + label: Some(label), + kind: InternalSymbolKind::Enumeration, + deprecated: false, + full_range, + selection_range, + children: Vec::new(), + } + } + None => InternalSymbol { + name, + label: None, + kind: InternalSymbolKind::Enumeration, + deprecated: false, + full_range, + selection_range: full_range, + children: Vec::new(), + }, + }; + Some(symbol) +} + +fn visit_float( + context: &mut Context, + node: &latex::SyntaxNode, + float_kind: LabelledFloatKind, +) -> Option<InternalSymbol> { + let environment = latex::Environment::cast(node)?; + let full_range = context + .subset + .documents + .first()? + .line_index + .line_col_lsp_range(environment.small_range()); + + let (float_kind, symbol_kind) = match float_kind { + LabelledFloatKind::Algorithm => ("Algorithm", InternalSymbolKind::Algorithm), + LabelledFloatKind::Figure => ("Figure", InternalSymbolKind::Figure), + LabelledFloatKind::Listing => ("Listing", InternalSymbolKind::Listing), + LabelledFloatKind::Table => ("Table", InternalSymbolKind::Table), + }; + + let caption = find_caption_by_parent(environment.syntax())?; + let symbol = match find_label_by_parent(context, environment.syntax()) { + Some(NumberedLabel { + name: label, + range: selection_range, + number, + }) => { + let name = match number { + Some(number) => format!("{} {}: {}", float_kind, number, caption), + None => format!("{}: {}", float_kind, caption), + }; + + InternalSymbol { + name, + label: Some(label), + kind: symbol_kind, + deprecated: false, + full_range, + selection_range, + children: Vec::new(), + } + } + None => InternalSymbol { + name: format!("{}: {}", float_kind, caption), + label: None, + kind: symbol_kind, + deprecated: false, + full_range, + selection_range: full_range, + children: Vec::new(), + }, + }; + + Some(symbol) +} + +fn visit_theorem( + context: &mut Context, + node: &latex::SyntaxNode, + environment_name: &str, +) -> Option<InternalSymbol> { + let definition = context + .subset + .documents + .iter() + .filter_map(|document| document.data.as_latex()) + .find_map(|data| { + data.extras + .theorem_environments + .iter() + .find(|environment| environment.name == environment_name) + .cloned() + })?; + + let node = latex::Environment::cast(node)?; + let theorem_description = node + .begin()? + .options() + .and_then(|option| option.content_text()); + + let full_range = context + .subset + .documents + .first()? + .line_index + .line_col_lsp_range(node.small_range()); + + let symbol = match find_label_by_parent(context, node.syntax()) { + Some(NumberedLabel { + name: label, + range: selection_range, + number, + }) => { + let name = match (number, theorem_description) { + (Some(number), Some(desc)) => { + format!("{} {} ({})", definition.description, number, desc) + } + (Some(number), None) => format!("{} {}", definition.description, number), + (None, Some(desc)) => format!("{} ({})", definition.description, desc), + (None, None) => definition.description.clone(), + }; + + InternalSymbol { + name, + label: Some(label), + kind: InternalSymbolKind::Theorem, + deprecated: false, + full_range, + selection_range, + children: Vec::new(), + } + } + None => { + let name = match theorem_description { + Some(desc) => format!("{} ({})", definition.description, desc), + None => definition.description.to_string(), + }; + InternalSymbol { + name, + label: None, + kind: InternalSymbolKind::Theorem, + deprecated: false, + full_range, + selection_range: full_range, + children: Vec::new(), + } + } + }; + Some(symbol) +} + +#[derive(Debug, PartialEq, Eq, Clone)] +struct NumberedLabel { + name: String, + range: Range, + number: Option<SmolStr>, +} + +fn find_label_by_parent( + context: &mut Context, + parent: &latex::SyntaxNode, +) -> Option<NumberedLabel> { + let node = parent.children().find_map(latex::LabelDefinition::cast)?; + + let name = node.name()?.word()?.text(); + let range = context + .subset + .documents + .first()? + .line_index + .line_col_lsp_range(node.small_range()); + + let number = find_label_number(&context.subset, name); + Some(NumberedLabel { + name: name.to_string(), + range, + number: number.map(Into::into), + }) +} diff --git a/support/texlab/src/features/symbol/project_order.rs b/support/texlab/src/features/symbol/project_order.rs new file mode 100644 index 0000000000..08b43ad50c --- /dev/null +++ b/support/texlab/src/features/symbol/project_order.rs @@ -0,0 +1,223 @@ +use std::{sync::Arc, usize}; + +use petgraph::{algo::tarjan_scc, Directed, Graph}; +use rustc_hash::FxHashSet; + +use crate::{Uri, Workspace, WorkspaceSubset}; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ProjectOrdering { + ordering: Vec<Arc<Uri>>, +} + +impl ProjectOrdering { + pub fn get(&self, uri: &Uri) -> usize { + self.ordering + .iter() + .position(|u| u.as_ref() == uri) + .unwrap_or(usize::MAX) + } +} + +impl From<&dyn Workspace> for ProjectOrdering { + fn from(workspace: &dyn Workspace) -> Self { + let mut ordering = Vec::new(); + let uris: FxHashSet<Arc<Uri>> = workspace + .documents() + .into_iter() + .map(|document| Arc::clone(&document.uri)) + .collect(); + + let comps = connected_components(workspace); + for comp in comps { + let graph = build_dependency_graph(&comp); + + let mut visited = FxHashSet::default(); + let root_index = *graph.node_weight(tarjan_scc(&graph)[0][0]).unwrap(); + let mut stack = vec![Arc::clone(&comp.documents[root_index].uri)]; + + while let Some(uri) = stack.pop() { + if !visited.insert(Arc::clone(&uri)) { + continue; + } + + ordering.push(Arc::clone(&uri)); + if let Some(document) = workspace.get(&uri) { + if let Some(data) = document.data.as_latex() { + for link in data.extras.explicit_links.iter().rev() { + for target in &link.targets { + if uris.contains(target.as_ref()) { + stack.push(Arc::clone(target)); + } + } + } + } + } + } + } + + Self { ordering } + } +} + +fn connected_components(workspace: &dyn Workspace) -> Vec<WorkspaceSubset> { + let mut components = Vec::new(); + let mut visited = FxHashSet::default(); + for root_document in workspace.documents() { + if !visited.insert(Arc::clone(&root_document.uri)) { + continue; + } + + let subset = workspace.subset(Arc::clone(&root_document.uri)).unwrap(); + for document in &subset.documents { + visited.insert(Arc::clone(&document.uri)); + } + components.push(subset); + } + components +} + +fn build_dependency_graph(subset: &WorkspaceSubset) -> Graph<usize, (), Directed> { + let mut graph = Graph::new(); + let nodes: Vec<_> = (0..subset.documents.len()) + .map(|i| graph.add_node(i)) + .collect(); + + for (i, document) in subset.documents.iter().enumerate() { + if let Some(data) = document.data.as_latex() { + for link in &data.extras.explicit_links { + for target in &link.targets { + if let Some(j) = subset + .documents + .iter() + .position(|document| document.uri.as_ref() == target.as_ref()) + { + graph.add_edge(nodes[j], nodes[i], ()); + break; + } + } + } + } + } + graph +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + + use crate::{create_workspace_fast, DocumentLanguage, ServerContext, WorkspaceSource}; + + use super::*; + + #[test] + fn test_no_cycles() -> Result<()> { + let workspace: Arc<dyn Workspace> = Arc::new(create_workspace_fast(Arc::new( + ServerContext::new(std::env::temp_dir()), + ))?); + + let a = workspace.open( + Arc::new(Uri::parse("http://example.com/a.tex")?), + "".into(), + DocumentLanguage::Latex, + WorkspaceSource::Client, + ); + + let b = workspace.open( + Arc::new(Uri::parse("http://example.com/b.tex")?), + "".into(), + DocumentLanguage::Latex, + WorkspaceSource::Client, + ); + + let c = workspace.open( + Arc::new(Uri::parse("http://example.com/c.tex")?), + r#"\include{b}\include{a}"#.into(), + DocumentLanguage::Latex, + WorkspaceSource::Client, + ); + + let ordering = ProjectOrdering::from(workspace.as_ref()); + + assert_eq!(ordering.get(&a.uri), 2); + assert_eq!(ordering.get(&b.uri), 1); + assert_eq!(ordering.get(&c.uri), 0); + Ok(()) + } + + #[test] + fn test_cycles() -> Result<()> { + let workspace: Arc<dyn Workspace> = Arc::new(create_workspace_fast(Arc::new( + ServerContext::new(std::env::temp_dir()), + ))?); + + let a = workspace.open( + Arc::new(Uri::parse("http://example.com/a.tex")?), + r#"\include{b}"#.into(), + DocumentLanguage::Latex, + WorkspaceSource::Client, + ); + + let b = workspace.open( + Arc::new(Uri::parse("http://example.com/b.tex")?), + r#"\include{a}"#.into(), + DocumentLanguage::Latex, + WorkspaceSource::Client, + ); + + let c = workspace.open( + Arc::new(Uri::parse("http://example.com/c.tex")?), + r#"\include{a}"#.into(), + DocumentLanguage::Latex, + WorkspaceSource::Client, + ); + + let ordering = ProjectOrdering::from(workspace.as_ref()); + + assert_eq!(ordering.get(&a.uri), 1); + assert_eq!(ordering.get(&b.uri), 2); + assert_eq!(ordering.get(&c.uri), 0); + Ok(()) + } + + #[test] + fn test_multiple_roots() -> Result<()> { + let workspace: Arc<dyn Workspace> = Arc::new(create_workspace_fast(Arc::new( + ServerContext::new(std::env::temp_dir()), + ))?); + + let a = workspace.open( + Arc::new(Uri::parse("http://example.com/a.tex")?), + r#"\include{b}"#.into(), + DocumentLanguage::Latex, + WorkspaceSource::Client, + ); + + let b = workspace.open( + Arc::new(Uri::parse("http://example.com/b.tex")?), + r#""#.into(), + DocumentLanguage::Latex, + WorkspaceSource::Client, + ); + + let c = workspace.open( + Arc::new(Uri::parse("http://example.com/c.tex")?), + r#""#.into(), + DocumentLanguage::Latex, + WorkspaceSource::Client, + ); + + let d = workspace.open( + Arc::new(Uri::parse("http://example.com/d.tex")?), + r#"\include{c}"#.into(), + DocumentLanguage::Latex, + WorkspaceSource::Client, + ); + + let ordering = ProjectOrdering::from(workspace.as_ref()); + + assert!(ordering.get(&a.uri) < ordering.get(&b.uri)); + assert!(ordering.get(&d.uri) < ordering.get(&c.uri)); + Ok(()) + } +} diff --git a/support/texlab/src/features/symbol/types.rs b/support/texlab/src/features/symbol/types.rs new file mode 100644 index 0000000000..bb00a932d8 --- /dev/null +++ b/support/texlab/src/features/symbol/types.rs @@ -0,0 +1,109 @@ +use lsp_types::{DocumentSymbol, Location, Range, SymbolInformation, SymbolKind}; + +use crate::{features::lsp_kinds::Structure, BibtexEntryTypeCategory, Uri}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum InternalSymbolKind { + Section, + Figure, + Algorithm, + Table, + Listing, + Enumeration, + EnumerationItem, + Theorem, + Equation, + Entry(BibtexEntryTypeCategory), + Field, + String, +} + +impl InternalSymbolKind { + pub fn into_symbol_kind(self) -> SymbolKind { + match self { + Self::Section => Structure::Section.symbol_kind(), + Self::Figure | Self::Algorithm | Self::Table | Self::Listing => { + Structure::Float.symbol_kind() + } + Self::Enumeration => Structure::Environment.symbol_kind(), + Self::EnumerationItem => Structure::Item.symbol_kind(), + Self::Theorem => Structure::Theorem.symbol_kind(), + Self::Equation => Structure::Equation.symbol_kind(), + Self::Entry(category) => Structure::Entry(category).symbol_kind(), + Self::Field => Structure::Field.symbol_kind(), + Self::String => Structure::Entry(BibtexEntryTypeCategory::String).symbol_kind(), + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct InternalSymbol { + pub name: String, + pub label: Option<String>, + pub kind: InternalSymbolKind, + pub deprecated: bool, + pub full_range: Range, + pub selection_range: Range, + pub children: Vec<InternalSymbol>, +} + +impl InternalSymbol { + pub fn search_text(&self) -> String { + let kind = match self.kind { + InternalSymbolKind::Section => "latex section", + InternalSymbolKind::Figure => "latex float figure", + InternalSymbolKind::Algorithm => "latex float algorithm", + InternalSymbolKind::Table => "latex float table", + InternalSymbolKind::Listing => "latex float listing", + InternalSymbolKind::Enumeration => "latex enumeration", + InternalSymbolKind::EnumerationItem => "latex enumeration item", + InternalSymbolKind::Theorem => "latex math", + InternalSymbolKind::Equation => "latex math equation", + InternalSymbolKind::Entry(_) => "bibtex entry", + InternalSymbolKind::Field => "bibtex field", + InternalSymbolKind::String => "bibtex string", + }; + format!("{} {}", kind, self.name).to_lowercase() + } + + pub fn flatten(mut self, buffer: &mut Vec<Self>) { + if self.kind == InternalSymbolKind::Field { + return; + } + for symbol in self.children.drain(..) { + symbol.flatten(buffer); + } + buffer.push(self); + } + + pub fn into_document_symbol(self) -> DocumentSymbol { + let children = self + .children + .into_iter() + .map(|child| child.into_document_symbol()) + .collect(); + #[allow(deprecated)] + DocumentSymbol { + name: self.name, + detail: self.label, + kind: self.kind.into_symbol_kind(), + deprecated: Some(self.deprecated), + range: self.full_range, + selection_range: self.selection_range, + children: Some(children), + tags: None, + } + } + + pub fn into_symbol_info(self, uri: Uri) -> SymbolInformation { + #[allow(deprecated)] + SymbolInformation { + name: self.name, + kind: self.kind.into_symbol_kind(), + deprecated: Some(self.deprecated), + location: Location::new(uri.into(), self.full_range), + container_name: None, + tags: None, + } + } +} |