diff options
Diffstat (limited to 'support/texlab/src/hover/latex')
-rw-r--r-- | support/texlab/src/hover/latex/citation.rs | 179 | ||||
-rw-r--r-- | support/texlab/src/hover/latex/component.rs | 95 | ||||
-rw-r--r-- | support/texlab/src/hover/latex/label.rs | 118 | ||||
-rw-r--r-- | support/texlab/src/hover/latex/mod.rs | 6 | ||||
-rw-r--r-- | support/texlab/src/hover/latex/preview.rs | 338 |
5 files changed, 736 insertions, 0 deletions
diff --git a/support/texlab/src/hover/latex/citation.rs b/support/texlab/src/hover/latex/citation.rs new file mode 100644 index 0000000000..8b5468e52e --- /dev/null +++ b/support/texlab/src/hover/latex/citation.rs @@ -0,0 +1,179 @@ +use crate::{ + citeproc::render_citation, + feature::{FeatureProvider, FeatureRequest}, + protocol::{Hover, HoverContents, RangeExt, TextDocumentPositionParams}, + syntax::{bibtex, Span, SyntaxNode}, + workspace::DocumentContent, +}; +use async_trait::async_trait; +use log::warn; + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +pub struct LatexCitationHoverProvider; + +#[async_trait] +impl FeatureProvider for LatexCitationHoverProvider { + type Params = TextDocumentPositionParams; + type Output = Option<Hover>; + + async fn execute<'a>(&'a self, req: &'a FeatureRequest<Self::Params>) -> Self::Output { + let (tree, src_key, entry) = Self::get_entry(req)?; + if entry.is_comment() { + None + } else { + let key = entry.key.as_ref()?; + match render_citation(&tree, key.text()) { + Some(markdown) => Some(Hover { + contents: HoverContents::Markup(markdown), + range: Some(src_key.range()), + }), + None => { + warn!("Failed to render entry: {}", key.text()); + None + } + } + } + } +} + +impl LatexCitationHoverProvider { + fn get_entry( + req: &FeatureRequest<TextDocumentPositionParams>, + ) -> Option<(&bibtex::Tree, &Span, &bibtex::Entry)> { + let key = Self::get_key(req)?; + for tree in req + .related() + .iter() + .filter_map(|doc| doc.content.as_bibtex()) + { + for entry in tree + .children(tree.root) + .filter_map(|node| tree.as_entry(node)) + { + if let Some(current_key) = &entry.key { + if current_key.text() == key.text { + return Some((tree, key, entry)); + } + } + } + } + None + } + + fn get_key(req: &FeatureRequest<TextDocumentPositionParams>) -> Option<&Span> { + match &req.current().content { + DocumentContent::Latex(table) => table + .citations + .iter() + .flat_map(|citation| citation.keys(&table)) + .find(|key| key.range().contains(req.params.position)) + .map(|token| &token.span), + DocumentContent::Bibtex(tree) => tree + .children(tree.root) + .filter_map(|node| tree.as_entry(node)) + .filter_map(|entry| entry.key.as_ref()) + .find(|key| key.range().contains(req.params.position)) + .map(|token| &token.span), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + feature::FeatureTester, + protocol::{MarkupContent, MarkupKind, Range, RangeExt}, + }; + use indoc::indoc; + + #[tokio::test] + async fn empty_latex_document() { + let actual_hover = FeatureTester::new() + .file("main.tex", "") + .main("main.tex") + .position(0, 0) + .test_position(LatexCitationHoverProvider) + .await; + + assert_eq!(actual_hover, None); + } + + #[tokio::test] + async fn empty_bibtex_document() { + let actual_hover = FeatureTester::new() + .file("main.bib", "") + .main("main.bib") + .position(0, 0) + .test_position(LatexCitationHoverProvider) + .await; + + assert_eq!(actual_hover, None); + } + + #[tokio::test] + async fn inside_label() { + let actual_hover = FeatureTester::new() + .file( + "main.bib", + "@article{foo, author = {Foo Bar}, title = {Baz Qux}, year = 1337}", + ) + .file( + "main.tex", + indoc!( + r#" + \addbibresource{main.bib} + \cite{foo} + "# + ), + ) + .main("main.tex") + .position(1, 7) + .test_position(LatexCitationHoverProvider) + .await + .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); + } + + #[tokio::test] + async fn inside_entry() { + let actual_hover = FeatureTester::new() + .file( + "main.bib", + "@article{foo, author = {Foo Bar}, title = {Baz Qux}, year = 1337}", + ) + .file( + "main.tex", + indoc!( + r#" + \addbibresource{main.bib} + \cite{foo} + "# + ), + ) + .main("main.bib") + .position(0, 11) + .test_position(LatexCitationHoverProvider) + .await + .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/hover/latex/component.rs b/support/texlab/src/hover/latex/component.rs new file mode 100644 index 0000000000..5285ec795a --- /dev/null +++ b/support/texlab/src/hover/latex/component.rs @@ -0,0 +1,95 @@ +use crate::{ + components::COMPONENT_DATABASE, + feature::{FeatureProvider, FeatureRequest}, + protocol::{Hover, HoverContents, RangeExt, TextDocumentPositionParams}, + syntax::{LatexIncludeKind, SyntaxNode}, +}; +use async_trait::async_trait; + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +pub struct LatexComponentHoverProvider; + +#[async_trait] +impl FeatureProvider for LatexComponentHoverProvider { + type Params = TextDocumentPositionParams; + type Output = Option<Hover>; + + async fn execute<'a>(&'a self, req: &'a FeatureRequest<Self::Params>) -> Self::Output { + let table = req.current().content.as_latex()?; + for include in &table.includes { + match include.kind { + LatexIncludeKind::Package | LatexIncludeKind::Class => { + for path in include.paths(&table) { + if path.range().contains(req.params.position) { + let docs = COMPONENT_DATABASE.documentation(path.text())?; + return Some(Hover { + contents: HoverContents::Markup(docs), + range: Some(path.range()), + }); + } + } + } + _ => {} + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + feature::FeatureTester, + protocol::{Range, RangeExt}, + }; + + #[tokio::test] + async fn empty_latex_document() { + let actual_hover = FeatureTester::new() + .file("main.tex", "") + .main("main.tex") + .position(0, 0) + .test_position(LatexComponentHoverProvider) + .await; + + assert_eq!(actual_hover, None); + } + + #[tokio::test] + async fn empty_bibtex_document() { + let actual_hover = FeatureTester::new() + .file("main.bib", "") + .main("main.bib") + .position(0, 0) + .test_position(LatexComponentHoverProvider) + .await; + + assert_eq!(actual_hover, None); + } + + #[tokio::test] + async fn known_package() { + let actual_hover = FeatureTester::new() + .file("main.tex", r#"\usepackage{amsmath}"#) + .main("main.tex") + .position(0, 15) + .test_position(LatexComponentHoverProvider) + .await + .unwrap(); + + assert_eq!(actual_hover.range.unwrap(), Range::new_simple(0, 12, 0, 19)); + } + + #[tokio::test] + async fn unknown_class() { + let actual_hover = FeatureTester::new() + .file("main.tex", r#"\documentclass{abcdefghijklmnop}"#) + .main("main.tex") + .position(0, 20) + .test_position(LatexComponentHoverProvider) + .await; + + assert_eq!(actual_hover, None); + } +} diff --git a/support/texlab/src/hover/latex/label.rs b/support/texlab/src/hover/latex/label.rs new file mode 100644 index 0000000000..66724d442b --- /dev/null +++ b/support/texlab/src/hover/latex/label.rs @@ -0,0 +1,118 @@ +use crate::{ + feature::{DocumentView, FeatureProvider, FeatureRequest}, + outline::{Outline, OutlineContext}, + protocol::{Hover, HoverContents, Position, RangeExt, TextDocumentPositionParams}, + syntax::{latex, LatexLabelKind, SyntaxNode}, + workspace::{Document, DocumentContent}, +}; +use async_trait::async_trait; +use std::sync::Arc; + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +pub struct LatexLabelHoverProvider; + +#[async_trait] +impl FeatureProvider for LatexLabelHoverProvider { + type Params = TextDocumentPositionParams; + type Output = Option<Hover>; + + async fn execute<'a>(&'a self, req: &'a FeatureRequest<Self::Params>) -> Self::Output { + let table = req.current().content.as_latex()?; + let reference = Self::find_reference(table, req.params.position)?; + let (doc, def) = Self::find_definition(&req.view, reference)?; + + let snapshot = Arc::clone(&req.view.snapshot); + let view = DocumentView::analyze(snapshot, doc, &req.options, &req.current_dir); + let outline = Outline::analyze(&view, &req.options, &req.current_dir); + let outline_ctx = OutlineContext::parse(&view, &outline, def)?; + let markup = outline_ctx.documentation(); + Some(Hover { + contents: HoverContents::Markup(markup), + range: Some(reference.range()), + }) + } +} + +impl LatexLabelHoverProvider { + fn find_reference(table: &latex::SymbolTable, pos: Position) -> Option<&latex::Token> { + for label in &table.labels { + let names = label.names(&table); + if names.len() == 1 && table[label.parent].range().contains(pos) { + return Some(&label.names(&table)[0]); + } + + for name in &names { + if name.range().contains(pos) { + return Some(name); + } + } + } + None + } + + fn find_definition( + view: &DocumentView, + reference: &latex::Token, + ) -> Option<(Arc<Document>, latex::Label)> { + for doc in &view.related { + if let DocumentContent::Latex(table) = &doc.content { + for label in &table.labels { + if label.kind == LatexLabelKind::Definition { + for name in label.names(&table) { + if name.text() == reference.text() { + return Some((Arc::clone(&doc), *label)); + } + } + } + } + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + feature::FeatureTester, + protocol::{Range, RangeExt}, + }; + + #[tokio::test] + async fn empty_latex_document() { + let actual_hover = FeatureTester::new() + .file("main.tex", "") + .main("main.tex") + .position(0, 0) + .test_position(LatexLabelHoverProvider) + .await; + + assert_eq!(actual_hover, None); + } + + #[tokio::test] + async fn empty_bibtex_document() { + let actual_hover = FeatureTester::new() + .file("main.bib", "") + .main("main.bib") + .position(0, 0) + .test_position(LatexLabelHoverProvider) + .await; + + assert_eq!(actual_hover, None); + } + + #[tokio::test] + async fn section() { + let actual_hover = FeatureTester::new() + .file("main.tex", r#"\section{Foo}\label{sec:foo}"#) + .main("main.tex") + .position(0, 23) + .test_position(LatexLabelHoverProvider) + .await + .unwrap(); + + assert_eq!(actual_hover.range.unwrap(), Range::new_simple(0, 20, 0, 27)); + } +} diff --git a/support/texlab/src/hover/latex/mod.rs b/support/texlab/src/hover/latex/mod.rs new file mode 100644 index 0000000000..c119bfc832 --- /dev/null +++ b/support/texlab/src/hover/latex/mod.rs @@ -0,0 +1,6 @@ +#[cfg(feature = "citation")] +pub mod citation; + +pub mod component; +pub mod label; +pub mod preview; diff --git a/support/texlab/src/hover/latex/preview.rs b/support/texlab/src/hover/latex/preview.rs new file mode 100644 index 0000000000..8d2ca00cb5 --- /dev/null +++ b/support/texlab/src/hover/latex/preview.rs @@ -0,0 +1,338 @@ +use crate::{ + components::COMPONENT_DATABASE, + feature::{FeatureProvider, FeatureRequest}, + protocol::{ + ClientCapabilitiesExt, Hover, HoverContents, MarkupContent, MarkupKind, Range, RangeExt, + TextDocumentPositionParams, + }, + syntax::{latex, CharStream, LatexIncludeKind, SyntaxNode}, + tex::{CompileError, CompileParams, DistributionKind, Format}, + workspace::DocumentContent, +}; +use async_trait::async_trait; +use image::{png::PNGEncoder, ColorType, DynamicImage, GenericImageView, ImageBuffer, RgbaImage}; +use io::Cursor; +use log::warn; +use std::{io, process::Stdio, time::Duration}; +use tempfile::TempDir; +use thiserror::Error; +use tokio::process::Command; + +const PREVIEW_ENVIRONMENTS: &[&str] = &[ + "align", + "alignat", + "aligned", + "alignedat", + "algorithmic", + "array", + "Bmatrix", + "bmatrix", + "cases", + "CD", + "eqnarray", + "equation", + "gather", + "gathered", + "matrix", + "multline", + "pmatrix", + "smallmatrix", + "split", + "subarray", + "Vmatrix", + "vmatrix", +]; + +const IGNORED_PACKAGES: &[&str] = &["biblatex", "pgf", "tikz"]; + +#[derive(Debug, Clone, Copy)] +enum MathElement { + Environment(latex::Environment), + Equation(latex::Equation), + Inline(latex::Inline), +} + +impl MathElement { + fn range(&self, tree: &latex::Tree) -> Range { + match self { + Self::Environment(env) => env.range(tree), + Self::Equation(eq) => eq.range(tree), + Self::Inline(inline) => inline.range(tree), + } + } +} + +#[derive(Debug, Error)] +enum RenderError { + #[error("an I/O error occurred: `{0}`")] + IO(#[from] io::Error), + #[error("a compile error occurred: `{0}`")] + Compile(#[from] CompileError), + #[error("compilation failed")] + DviNotFound, + #[error("dvipng is not installed")] + DviPngNotInstalled, + #[error("calling dvipng failed")] + DviPngFaulty, + #[error("failed to decode image")] + DecodeImage, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +pub struct LatexPreviewHoverProvider; + +impl LatexPreviewHoverProvider { + fn is_preview_environment( + req: &FeatureRequest<TextDocumentPositionParams>, + table: &latex::SymbolTable, + environment: latex::Environment, + ) -> bool { + let canonical_name = environment + .left + .name(&table) + .map(latex::Token::text) + .unwrap_or_default() + .replace('*', ""); + + PREVIEW_ENVIRONMENTS.contains(&canonical_name.as_ref()) + || Self::theorem_environments(req).contains(&canonical_name.as_ref()) + } + + fn theorem_environments(req: &FeatureRequest<TextDocumentPositionParams>) -> Vec<&str> { + let mut names = Vec::new(); + for doc in req.related() { + if let DocumentContent::Latex(table) = &doc.content { + table + .theorem_definitions + .iter() + .map(|thm| thm.name(&table).text()) + .for_each(|thm| names.push(thm)); + } + } + names + } + + async fn render( + req: &FeatureRequest<TextDocumentPositionParams>, + range: Range, + ) -> Result<Hover, RenderError> { + let code = Self::generate_code(req, range); + let params = CompileParams { + file_name: "preview.tex", + code: &code, + format: Format::Latex, + timeout: Duration::from_secs(10), + }; + let dir = req.distro.compile(params).await?.dir; + if !dir.path().join("preview.dvi").exists() { + return Err(RenderError::DviNotFound); + } + + let img = Self::add_margin(Self::dvipng(&dir).await?); + let base64 = Self::encode_image(img); + let markdown = format!("![preview](data:image/png;base64,{})", base64); + dir.close()?; + Ok(Hover { + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value: markdown, + }), + range: Some(range), + }) + } + + fn generate_code(req: &FeatureRequest<TextDocumentPositionParams>, range: Range) -> String { + let mut code = String::new(); + code.push_str("\\documentclass{article}\n"); + code.push_str("\\thispagestyle{empty}\n"); + Self::generate_includes(req, &mut code); + Self::generate_command_definitions(req, &mut code); + Self::generate_math_operators(req, &mut code); + Self::generate_theorem_definitions(req, &mut code); + code.push_str("\\begin{document}\n"); + code.push_str(&CharStream::extract(&req.current().text, range)); + code.push('\n'); + code.push_str("\\end{document}\n"); + code + } + + fn generate_includes(req: &FeatureRequest<TextDocumentPositionParams>, code: &mut String) { + for doc in req.related() { + if let DocumentContent::Latex(table) = &doc.content { + let text = &doc.text; + for include in &table.includes { + if include.kind == LatexIncludeKind::Package { + if include + .paths(&table) + .iter() + .all(|path| IGNORED_PACKAGES.contains(&path.text())) + { + continue; + } + + if include + .paths(&table) + .iter() + .map(|path| format!("{}.sty", path.text())) + .any(|name| !COMPONENT_DATABASE.exists(&name)) + { + continue; + } + + code.push_str(&CharStream::extract(&text, table[include.parent].range())); + code.push('\n'); + } + } + } + } + } + + fn generate_command_definitions( + req: &FeatureRequest<TextDocumentPositionParams>, + code: &mut String, + ) { + for doc in req.related() { + if let DocumentContent::Latex(table) = &doc.content { + table + .command_definitions + .iter() + .filter(|def| !def.definition_name(&table).contains("@")) + .map(|def| CharStream::extract(&doc.text, table[def.parent].range())) + .for_each(|def| { + code.push_str(&def); + code.push('\n'); + }); + } + } + } + + fn generate_math_operators( + req: &FeatureRequest<TextDocumentPositionParams>, + code: &mut String, + ) { + for doc in req.related() { + if let DocumentContent::Latex(table) = &doc.content { + table + .math_operators + .iter() + .filter(|op| !op.definition_name(&table).contains("@")) + .map(|op| CharStream::extract(&doc.text, table[op.parent].range())) + .for_each(|op| { + code.push_str(&op); + code.push('\n'); + }); + } + } + } + + fn generate_theorem_definitions( + req: &FeatureRequest<TextDocumentPositionParams>, + code: &mut String, + ) { + for doc in req.related() { + if let DocumentContent::Latex(table) = &doc.content { + table + .theorem_definitions + .iter() + .map(|thm| CharStream::extract(&doc.text, table[thm.parent].range())) + .for_each(|thm| { + code.push_str(&thm); + code.push('\n'); + }) + } + } + } + + async fn dvipng(dir: &TempDir) -> Result<DynamicImage, RenderError> { + let process = Command::new("dvipng") + .args(&["-D", "175", "-T", "tight", "preview.dvi"]) + .current_dir(dir.path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| RenderError::DviPngNotInstalled)?; + + process.await.map_err(|_| RenderError::DviPngFaulty)?; + + let png_file = dir.path().join("preview1.png"); + let png = image::open(png_file).map_err(|_| RenderError::DecodeImage)?; + Ok(png) + } + + fn add_margin(image: DynamicImage) -> RgbaImage { + let margin = 5; + let width = image.width() + 2 * margin; + let height = image.height() + 2 * margin; + let mut result = + ImageBuffer::from_pixel(width, height, image::Rgba([0xFF, 0xFF, 0xFF, 0xFF])); + + for x in 0..image.width() { + for y in 0..image.height() { + let pixel = image.get_pixel(x, y); + result.put_pixel(x + margin, y + margin, pixel); + } + } + result + } + + fn encode_image(image: RgbaImage) -> String { + let mut image_buf = Cursor::new(Vec::new()); + let png_encoder = PNGEncoder::new(&mut image_buf); + let width = image.width(); + let height = image.height(); + png_encoder + .encode(&image.into_raw(), width, height, ColorType::Rgba8) + .unwrap(); + base64::encode(&image_buf.into_inner()) + } +} + +#[async_trait] +impl FeatureProvider for LatexPreviewHoverProvider { + type Params = TextDocumentPositionParams; + type Output = Option<Hover>; + + async fn execute<'a>(&'a self, req: &'a FeatureRequest<Self::Params>) -> Self::Output { + if !req.client_capabilities.has_hover_markdown_support() + || req.distro.kind() == DistributionKind::Tectonic + { + return None; + } + + if let DocumentContent::Latex(table) = &req.current().content { + let mut elements = Vec::new(); + table + .inlines + .iter() + .map(|inline| MathElement::Inline(*inline)) + .for_each(|inline| elements.push(inline)); + + table + .equations + .iter() + .map(|eq| MathElement::Equation(*eq)) + .for_each(|eq| elements.push(eq)); + + table + .environments + .iter() + .filter(|env| Self::is_preview_environment(req, table, **env)) + .map(|env| MathElement::Environment(*env)) + .for_each(|env| elements.push(env)); + + let range = elements + .iter() + .map(|elem| elem.range(&table)) + .find(|range| range.contains(req.params.position))?; + + return match Self::render(req, range).await { + Ok(hover) => Some(hover), + Err(why) => { + warn!("Preview failed: {}", why); + None + } + }; + } + None + } +} |