diff options
Diffstat (limited to 'support/texlab/src/tex')
-rw-r--r-- | support/texlab/src/tex/compile.rs | 97 | ||||
-rw-r--r-- | support/texlab/src/tex/kpsewhich.rs | 112 | ||||
-rw-r--r-- | support/texlab/src/tex/miktex.rs | 108 | ||||
-rw-r--r-- | support/texlab/src/tex/mod.rs | 154 | ||||
-rw-r--r-- | support/texlab/src/tex/tectonic.rs | 36 | ||||
-rw-r--r-- | support/texlab/src/tex/texlive.rs | 71 |
6 files changed, 578 insertions, 0 deletions
diff --git a/support/texlab/src/tex/compile.rs b/support/texlab/src/tex/compile.rs new file mode 100644 index 0000000000..a2b0d2f37d --- /dev/null +++ b/support/texlab/src/tex/compile.rs @@ -0,0 +1,97 @@ +use std::{io, process::Stdio, time::Duration}; +use tempfile::{tempdir, TempDir}; +use thiserror::Error; +use tokio::{ + fs, + process::Command, + time::{timeout, Elapsed}, +}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum Format { + Latex, + Pdflatex, + Xelatex, + Lualatex, +} + +impl Format { + pub fn executable(self) -> &'static str { + match self { + Self::Latex => "latex", + Self::Pdflatex => "pdflatex", + Self::Xelatex => "xelatex", + Self::Lualatex => "lualatex", + } + } +} + +#[derive(Debug)] +pub struct Artifacts { + pub dir: TempDir, + pub log: String, +} + +#[derive(Debug, Error)] +pub enum CompileError { + #[error("an I/O error occurred: `{0}`")] + IO(#[from] io::Error), + #[error("TeX engine is not installed")] + NotInstalled, + #[error("build timeout: `{0}`")] + Timeout(#[from] Elapsed), +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct CompileParams<'a> { + pub format: Format, + pub file_name: &'a str, + pub code: &'a str, + pub timeout: Duration, +} + +impl<'a> Default for CompileParams<'a> { + fn default() -> Self { + Self { + format: Format::Lualatex, + file_name: "code.tex", + code: "", + timeout: Duration::from_secs(15), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Compiler<'a> { + pub executable: &'a str, + pub args: &'a [&'a str], + pub file_name: &'a str, + pub timeout: Duration, +} + +impl<'a> Compiler<'a> { + pub async fn compile<'b>(&'a self, code: &'b str) -> Result<Artifacts, CompileError> { + let directory = tempdir()?; + let tex_file = directory.path().join(self.file_name); + fs::write(&tex_file, code).await?; + + let child = Command::new(self.executable) + .args(self.args) + .current_dir(&directory) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + timeout(self.timeout, child) + .await? + .map_err(|_| CompileError::NotInstalled)?; + + let log_file = tex_file.with_extension("log"); + let log_bytes = fs::read(log_file).await?; + let log = String::from_utf8_lossy(&log_bytes).into_owned(); + Ok(Artifacts { + dir: directory, + log, + }) + } +} diff --git a/support/texlab/src/tex/kpsewhich.rs b/support/texlab/src/tex/kpsewhich.rs new file mode 100644 index 0000000000..cb2ab89de5 --- /dev/null +++ b/support/texlab/src/tex/kpsewhich.rs @@ -0,0 +1,112 @@ +use super::Language; +use futures::Future; +use std::{ + collections::HashMap, + env, + ffi::OsStr, + io, + path::{Path, PathBuf}, + string::FromUtf8Error, +}; +use thiserror::Error; +use tokio::{fs, process::Command}; + +#[derive(Debug, Error)] +pub enum KpsewhichError { + #[error("an I/O error occurred: `{0}`")] + IO(#[from] io::Error), + #[error("an utf8 error occurred: `{0}`")] + Decode(#[from] FromUtf8Error), + #[error("invalid output from kpsewhich")] + InvalidOutput, + #[error("kpsewhich not installed")] + NotInstalled, + #[error("no kpsewhich database")] + NoDatabase, + #[error("corrupt kpsewhich database")] + CorruptDatabase, +} + +#[derive(Debug, PartialEq, Eq, Clone, Default)] +pub struct Resolver { + pub files_by_name: HashMap<String, PathBuf>, +} + +impl Resolver { + pub fn new(files_by_name: HashMap<String, PathBuf>) -> Self { + Self { files_by_name } + } +} + +pub async fn parse_database<'a, R, F>( + root_directories: &'a [PathBuf], + reader: R, +) -> Result<Resolver, KpsewhichError> +where + R: Fn(&'a Path) -> F, + F: Future<Output = Result<Vec<PathBuf>, KpsewhichError>>, +{ + let mut files_by_name = HashMap::new(); + for directory in root_directories { + for path in reader(directory).await? { + if is_tex_file(&path) { + if let Some(path) = make_absolute(root_directories, &path).await { + if let Some(name) = path + .file_name() + .and_then(OsStr::to_str) + .map(ToString::to_string) + { + files_by_name.insert(name, path); + } + } + } + } + } + Ok(Resolver::new(files_by_name)) +} + +fn is_tex_file(path: &Path) -> bool { + path.extension() + .and_then(OsStr::to_str) + .and_then(Language::by_extension) + .is_some() +} + +async fn make_absolute(root_directories: &[PathBuf], relative_path: &Path) -> Option<PathBuf> { + for dir in root_directories.iter().rev() { + if let Ok(path) = fs::canonicalize(dir.join(&relative_path)).await { + return Some(path); + } + } + None +} + +pub async fn root_directories() -> Result<Vec<PathBuf>, KpsewhichError> { + let texmf = run(&["-var-value", "TEXMF"]).await?; + let expand_arg = format!("--expand-braces={}", texmf); + let expanded = run(&[&expand_arg]).await?; + let directories = env::split_paths(&expanded.replace("!", "")) + .filter(|path| path.exists()) + .collect(); + Ok(directories) +} + +async fn run<I, S>(args: I) -> Result<String, KpsewhichError> +where + I: IntoIterator<Item = S>, + S: AsRef<OsStr>, +{ + let output = Command::new("kpsewhich") + .args(args) + .output() + .await + .map_err(|_| KpsewhichError::NotInstalled)?; + + let result = String::from_utf8(output.stdout)? + .lines() + .next() + .ok_or(KpsewhichError::InvalidOutput)? + .into(); + + Ok(result) +} diff --git a/support/texlab/src/tex/miktex.rs b/support/texlab/src/tex/miktex.rs new file mode 100644 index 0000000000..9f1551df44 --- /dev/null +++ b/support/texlab/src/tex/miktex.rs @@ -0,0 +1,108 @@ +use super::{ + compile, + kpsewhich::{self, KpsewhichError, Resolver}, + Artifacts, CompileError, CompileParams, Distribution, DistributionKind, +}; +use async_trait::async_trait; +use byteorder::{LittleEndian, ReadBytesExt}; +use futures::lock::Mutex; +use std::{ + ffi::OsStr, + io::{self, Cursor}, + mem, + path::{Path, PathBuf}, + sync::Arc, +}; +use tokio::fs; + +#[derive(Debug, Default)] +pub struct Miktex { + resolver: Mutex<Arc<Resolver>>, +} + +#[async_trait] +impl Distribution for Miktex { + fn kind(&self) -> DistributionKind { + DistributionKind::Miktex + } + + async fn compile<'a>(&'a self, params: CompileParams<'a>) -> Result<Artifacts, CompileError> { + compile(params).await + } + + async fn load(&self) -> Result<(), KpsewhichError> { + let root_directories = kpsewhich::root_directories().await?; + let resolver = kpsewhich::parse_database(&root_directories, read_database).await?; + mem::replace(&mut *self.resolver.lock().await, Arc::new(resolver)); + Ok(()) + } + + async fn resolver(&self) -> Arc<Resolver> { + let resolver = self.resolver.lock().await; + Arc::clone(&resolver) + } +} + +const DATABASE_PATH: &str = "miktex/data/le"; +const FNDB_SIGNATURE: u32 = 0x42_44_4e_46; +const FNDB_WORD_SIZE: usize = 4; +const FNDB_TABLE_POINTER_OFFSET: usize = 4 * FNDB_WORD_SIZE; +const FNDB_TABLE_SIZE_OFFSET: usize = 6 * FNDB_WORD_SIZE; +const FNDB_ENTRY_SIZE: usize = 4 * FNDB_WORD_SIZE; + +async fn read_database(directory: &Path) -> Result<Vec<PathBuf>, KpsewhichError> { + let database_directory = directory.join(DATABASE_PATH); + if !database_directory.exists() { + return Ok(Vec::new()); + } + + let mut database = Vec::new(); + let mut files: tokio::fs::ReadDir = fs::read_dir(database_directory).await?; + while let Some(file) = files.next_entry().await? { + if file.path().extension().and_then(OsStr::to_str) == Some("fndb-5") { + let bytes = fs::read(file.path()).await?; + database.extend(parse_database(&bytes).map_err(|_| KpsewhichError::CorruptDatabase)?); + } + } + Ok(database) +} + +fn parse_database(bytes: &[u8]) -> io::Result<Vec<PathBuf>> { + let mut reader = Cursor::new(bytes); + if reader.read_u32::<LittleEndian>()? != FNDB_SIGNATURE { + return Err(io::ErrorKind::InvalidData.into()); + } + + reader.set_position(FNDB_TABLE_POINTER_OFFSET as u64); + let table_address = reader.read_u32::<LittleEndian>()?; + + reader.set_position(FNDB_TABLE_SIZE_OFFSET as u64); + let table_size = reader.read_u32::<LittleEndian>()?; + + let mut files = Vec::new(); + for i in 0..table_size { + let offset = table_address + i * FNDB_ENTRY_SIZE as u32; + reader.set_position(offset as u64); + let file_name_offset = reader.read_u32::<LittleEndian>()? as usize; + let directory_offset = reader.read_u32::<LittleEndian>()? as usize; + let file_name = read_string(bytes, file_name_offset)?; + let directory = read_string(bytes, directory_offset)?; + + let file = PathBuf::from(directory).join(file_name); + files.push(file); + } + + Ok(files) +} + +fn read_string(bytes: &[u8], offset: usize) -> io::Result<&str> { + let mut byte = bytes[offset]; + let mut length = 0; + while byte != 0x00 { + length += 1; + byte = bytes[offset + length]; + } + + std::str::from_utf8(&bytes[offset..offset + length]) + .map_err(|_| io::ErrorKind::InvalidData.into()) +} diff --git a/support/texlab/src/tex/mod.rs b/support/texlab/src/tex/mod.rs new file mode 100644 index 0000000000..6d60380e52 --- /dev/null +++ b/support/texlab/src/tex/mod.rs @@ -0,0 +1,154 @@ +mod compile; +mod kpsewhich; +mod miktex; +mod tectonic; +mod texlive; + +pub use self::{ + compile::{Artifacts, CompileError, CompileParams, Format}, + kpsewhich::{KpsewhichError, Resolver}, +}; + +use self::{compile::Compiler, miktex::Miktex, tectonic::Tectonic, texlive::Texlive}; +use async_trait::async_trait; +use std::{fmt, process::Stdio, sync::Arc}; +use tokio::process::Command; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum DistributionKind { + Texlive, + Miktex, + Tectonic, + Unknown, +} + +impl fmt::Display for DistributionKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + Self::Texlive => "TeXLive", + Self::Miktex => "MikTeX", + Self::Tectonic => "Tectonic", + Self::Unknown => "Unknown", + }; + write!(f, "{}", name) + } +} + +impl DistributionKind { + pub async fn detect() -> Self { + if Command::new("tectonic") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .is_ok() + { + return Self::Tectonic; + } + + match Command::new("latex").arg("--version").output().await { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.contains("TeX Live") { + Self::Texlive + } else if stdout.contains("MiKTeX") { + Self::Miktex + } else { + Self::Unknown + } + } + Err(_) => Self::Unknown, + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum Language { + Latex, + Bibtex, +} + +impl Language { + pub fn by_extension(extension: &str) -> Option<Self> { + match extension.to_lowercase().as_str() { + "tex" | "sty" | "cls" | "def" | "lco" | "aux" => Some(Language::Latex), + "bib" | "bibtex" => Some(Language::Bibtex), + _ => None, + } + } + + pub fn by_language_id(language_id: &str) -> Option<Self> { + match language_id { + "latex" | "tex" => Some(Language::Latex), + "bibtex" | "bib" => Some(Language::Bibtex), + _ => None, + } + } +} + +#[async_trait] +pub trait Distribution: Send + Sync { + fn kind(&self) -> DistributionKind; + + async fn compile<'a>(&'a self, params: CompileParams<'a>) -> Result<Artifacts, CompileError>; + + async fn load(&self) -> Result<(), KpsewhichError>; + + async fn resolver(&self) -> Arc<Resolver>; +} + +impl dyn Distribution { + pub async fn detect() -> Arc<dyn Distribution> { + let kind = DistributionKind::detect().await; + let distro: Arc<dyn Distribution + Send + Sync> = match kind { + DistributionKind::Texlive => Arc::new(Texlive::default()), + DistributionKind::Miktex => Arc::new(Miktex::default()), + DistributionKind::Tectonic => Arc::new(Tectonic::default()), + DistributionKind::Unknown => Arc::new(UnknownDistribution::default()), + }; + distro + } +} + +impl fmt::Debug for dyn Distribution { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.kind()) + } +} + +async fn compile(params: CompileParams<'_>) -> Result<Artifacts, CompileError> { + let executable = params.format.executable(); + let args = &["--interaction=batchmode", "-shell-escape", params.file_name]; + let compiler = Compiler { + executable, + args, + file_name: params.file_name, + timeout: params.timeout, + }; + compiler.compile(params.code).await +} + +#[derive(Debug, Default)] +pub struct UnknownDistribution { + resolver: Arc<Resolver>, +} + +#[async_trait] +impl Distribution for UnknownDistribution { + fn kind(&self) -> DistributionKind { + DistributionKind::Unknown + } + + async fn compile<'a>(&'a self, _params: CompileParams<'a>) -> Result<Artifacts, CompileError> { + Err(CompileError::NotInstalled) + } + + async fn load(&self) -> Result<(), KpsewhichError> { + Ok(()) + } + + async fn resolver(&self) -> Arc<Resolver> { + Arc::clone(&self.resolver) + } +} diff --git a/support/texlab/src/tex/tectonic.rs b/support/texlab/src/tex/tectonic.rs new file mode 100644 index 0000000000..1c19466830 --- /dev/null +++ b/support/texlab/src/tex/tectonic.rs @@ -0,0 +1,36 @@ +use super::{ + compile::{Artifacts, CompileError, CompileParams, Compiler}, + kpsewhich::{KpsewhichError, Resolver}, + Distribution, DistributionKind, +}; +use async_trait::async_trait; +use std::sync::Arc; + +#[derive(Debug, Default)] +pub struct Tectonic; + +#[async_trait] +impl Distribution for Tectonic { + fn kind(&self) -> DistributionKind { + DistributionKind::Tectonic + } + + async fn compile<'a>(&'a self, params: CompileParams<'a>) -> Result<Artifacts, CompileError> { + let args = [params.file_name]; + let compiler = Compiler { + executable: "tectonic", + args: &args, + file_name: params.file_name, + timeout: params.timeout, + }; + compiler.compile(params.code).await + } + + async fn load(&self) -> Result<(), KpsewhichError> { + Ok(()) + } + + async fn resolver(&self) -> Arc<Resolver> { + Arc::new(Resolver::default()) + } +} diff --git a/support/texlab/src/tex/texlive.rs b/support/texlab/src/tex/texlive.rs new file mode 100644 index 0000000000..64bcfebdb7 --- /dev/null +++ b/support/texlab/src/tex/texlive.rs @@ -0,0 +1,71 @@ +use super::{ + compile, + kpsewhich::{self, KpsewhichError, Resolver}, + Artifacts, CompileError, CompileParams, Distribution, DistributionKind, +}; +use async_trait::async_trait; +use futures::lock::Mutex; +use std::{ + io, mem, + path::{Path, PathBuf}, + str::Lines, + sync::Arc, +}; +use tokio::fs; + +#[derive(Debug, Default)] +pub struct Texlive { + resolver: Mutex<Arc<Resolver>>, +} + +#[async_trait] +impl Distribution for Texlive { + fn kind(&self) -> DistributionKind { + DistributionKind::Texlive + } + + async fn compile<'a>(&'a self, params: CompileParams<'a>) -> Result<Artifacts, CompileError> { + compile(params).await + } + + async fn load(&self) -> Result<(), KpsewhichError> { + let root_directories = kpsewhich::root_directories().await?; + let resolver = kpsewhich::parse_database(&root_directories, read_database).await?; + mem::replace(&mut *self.resolver.lock().await, Arc::new(resolver)); + Ok(()) + } + + async fn resolver(&self) -> Arc<Resolver> { + let resolver = self.resolver.lock().await; + Arc::clone(&resolver) + } +} + +const DATABASE_PATH: &str = "ls-R"; + +async fn read_database(directory: &Path) -> Result<Vec<PathBuf>, KpsewhichError> { + let file = directory.join(DATABASE_PATH); + if !file.is_file() { + return Ok(Vec::new()); + } + + let text = fs::read_to_string(file) + .await + .map_err(|_| KpsewhichError::NoDatabase)?; + parse_database(text.lines()).map_err(|_| KpsewhichError::CorruptDatabase) +} + +fn parse_database(lines: Lines) -> io::Result<Vec<PathBuf>> { + let mut paths = Vec::new(); + let mut directory = ""; + + for line in lines.filter(|x| !x.trim().is_empty() && !x.starts_with('%')) { + if line.ends_with(':') { + directory = &line[..line.len() - 1]; + } else { + let path = PathBuf::from(directory).join(line); + paths.push(path); + } + } + Ok(paths) +} |