summaryrefslogtreecommitdiff
path: root/support/texlab/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'support/texlab/src/features')
-rw-r--r--support/texlab/src/features/build.rs133
-rw-r--r--support/texlab/src/features/completion.rs567
-rw-r--r--support/texlab/src/features/completion/acronym_ref.rs169
-rw-r--r--support/texlab/src/features/completion/argument.rs189
-rw-r--r--support/texlab/src/features/completion/begin_command.rs84
-rw-r--r--support/texlab/src/features/completion/citation.rs244
-rw-r--r--support/texlab/src/features/completion/color.rs122
-rw-r--r--support/texlab/src/features/completion/color_model.rs188
-rw-r--r--support/texlab/src/features/completion/component_command.rs177
-rw-r--r--support/texlab/src/features/completion/component_environment.rs174
-rw-r--r--support/texlab/src/features/completion/entry_type.rs231
-rw-r--r--support/texlab/src/features/completion/field.rs200
-rw-r--r--support/texlab/src/features/completion/glossary_ref.rs141
-rw-r--r--support/texlab/src/features/completion/import.rs196
-rw-r--r--support/texlab/src/features/completion/include.rs181
-rw-r--r--support/texlab/src/features/completion/label.rs196
-rw-r--r--support/texlab/src/features/completion/theorem.rs152
-rw-r--r--support/texlab/src/features/completion/tikz_library.rs169
-rw-r--r--support/texlab/src/features/completion/types.rs150
-rw-r--r--support/texlab/src/features/completion/user_command.rs100
-rw-r--r--support/texlab/src/features/completion/user_environment.rs116
-rw-r--r--support/texlab/src/features/completion/util.rs66
-rw-r--r--support/texlab/src/features/cursor.rs252
-rw-r--r--support/texlab/src/features/definition.rs29
-rw-r--r--support/texlab/src/features/definition/command.rs131
-rw-r--r--support/texlab/src/features/definition/document.rs112
-rw-r--r--support/texlab/src/features/definition/entry.rs125
-rw-r--r--support/texlab/src/features/definition/label.rs80
-rw-r--r--support/texlab/src/features/definition/string.rs176
-rw-r--r--support/texlab/src/features/folding.rs237
-rw-r--r--support/texlab/src/features/formatting.rs24
-rw-r--r--support/texlab/src/features/formatting/bibtex_internal.rs381
-rw-r--r--support/texlab/src/features/formatting/latexindent.rs50
-rw-r--r--support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__command.snap8
-rw-r--r--support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__concatenation.snap8
-rw-r--r--support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__insert_braces.snap8
-rw-r--r--support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__multiple_entries.snap9
-rw-r--r--support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__parens.snap7
-rw-r--r--support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__preamble.snap6
-rw-r--r--support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__string.snap6
-rw-r--r--support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__trailing_comma.snap8
-rw-r--r--support/texlab/src/features/formatting/snapshots/texlab__features__formatting__bibtex_internal__tests__wrap_long_lines.snap10
-rw-r--r--support/texlab/src/features/forward_search.rs124
-rw-r--r--support/texlab/src/features/highlight.rs15
-rw-r--r--support/texlab/src/features/highlight/label.rs167
-rw-r--r--support/texlab/src/features/hover.rs43
-rw-r--r--support/texlab/src/features/hover/citation.rs133
-rw-r--r--support/texlab/src/features/hover/component.rs105
-rw-r--r--support/texlab/src/features/hover/entry_type.rs128
-rw-r--r--support/texlab/src/features/hover/field.rs128
-rw-r--r--support/texlab/src/features/hover/label.rs79
-rw-r--r--support/texlab/src/features/hover/string_ref.rs141
-rw-r--r--support/texlab/src/features/link.rs115
-rw-r--r--support/texlab/src/features/lsp_kinds.rs94
-rw-r--r--support/texlab/src/features/reference.rs25
-rw-r--r--support/texlab/src/features/reference/entry.rs198
-rw-r--r--support/texlab/src/features/reference/label.rs187
-rw-r--r--support/texlab/src/features/reference/string.rs206
-rw-r--r--support/texlab/src/features/rename.rs34
-rw-r--r--support/texlab/src/features/rename/command.rs98
-rw-r--r--support/texlab/src/features/rename/entry.rs161
-rw-r--r--support/texlab/src/features/rename/label.rs142
-rw-r--r--support/texlab/src/features/semantic.rs21
-rw-r--r--support/texlab/src/features/semantic/bibtex.rs289
-rw-r--r--support/texlab/src/features/semantic/legend.rs83
-rw-r--r--support/texlab/src/features/symbol.rs125
-rw-r--r--support/texlab/src/features/symbol/bibtex.rs88
-rw-r--r--support/texlab/src/features/symbol/latex.rs488
-rw-r--r--support/texlab/src/features/symbol/project_order.rs223
-rw-r--r--support/texlab/src/features/symbol/types.rs109
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 &param.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,
+ }
+ }
+}