use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use lsp_types::TextDocumentPositionParams;
use serde::{Deserialize, Serialize};
use crate::config::BuildConfig;
pub(crate) enum ForwardSearchRequest {}
impl lsp_types::request::Request for ForwardSearchRequest {
type Params = TextDocumentPositionParams;
type Result = ForwardSearchResult;
const METHOD: &'static str = "textDocument/forwardSearch";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ForwardSearchResult {
pub status: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub(crate) enum ForwardSearchStatus {
Success = 0,
Error = 1,
Failure = 2,
Unconfigured = 3,
}
impl ForwardSearchStatus {
pub(crate) fn code(self) -> u8 {
self as u8
}
pub(crate) fn result(self) -> ForwardSearchResult {
ForwardSearchResult {
status: self.code(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SearchTarget {
pub tex: PathBuf,
pub pdf: PathBuf,
pub line: u32,
}
pub(crate) fn pdf_path(root: &Path, build: &BuildConfig) -> Option<PathBuf> {
let dir = match build.pdf_dir.as_deref() {
Some(dir) if dir.is_absolute() => dir.to_path_buf(),
Some(dir) => root.parent().unwrap_or(Path::new("")).join(dir),
None => root.parent().unwrap_or(Path::new("")).to_path_buf(),
};
let name = match build.pdf_filename.as_deref() {
Some(name) if Path::new(name).extension().is_some() => PathBuf::from(name),
Some(name) => PathBuf::from(name).with_extension("pdf"),
None => PathBuf::from(root.file_stem()?).with_extension("pdf"),
};
Some(dir.join(name))
}
fn substitute(arg: &str, tex: &str, pdf: &str, line: u32) -> String {
if arg.len() >= 2 && arg.starts_with('"') && arg.ends_with('"') {
return arg[1..arg.len() - 1].to_owned();
}
let mut out = String::with_capacity(arg.len());
let mut chars = arg.chars();
while let Some(ch) = chars.next() {
if ch != '%' {
out.push(ch);
continue;
}
match chars.next() {
Some('f') => out.push_str(tex),
Some('p') => out.push_str(pdf),
Some('l') => out.push_str(&line.to_string()),
Some(other) => out.push(other),
None => out.push('%'),
}
}
out
}
pub(crate) fn viewer_args(args: &[String], target: &SearchTarget) -> Vec<String> {
let tex = target.tex.to_string_lossy();
let pdf = target.pdf.to_string_lossy();
args.iter()
.map(|arg| substitute(arg, &tex, &pdf, target.line))
.collect()
}
pub(crate) fn spawn_viewer(
executable: &str,
args: &[String],
target: &SearchTarget,
) -> ForwardSearchStatus {
let args = viewer_args(args, target);
match Command::new(executable)
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(mut child) => {
if std::thread::Builder::new()
.name("badness-viewer-reap".to_owned())
.spawn(move || {
let _ = child.wait();
})
.is_err()
{
log::warn!("forward search: could not spawn a reaper for `{executable}`");
}
ForwardSearchStatus::Success
}
Err(err) => {
log::error!("forward search: failed to launch `{executable}`: {err}");
ForwardSearchStatus::Error
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn target() -> SearchTarget {
SearchTarget {
tex: PathBuf::from("chapter.tex"),
pdf: PathBuf::from("main.pdf"),
line: 42,
}
}
fn subst(arg: &str) -> String {
substitute(arg, "chapter.tex", "main.pdf", 42)
}
#[test]
fn substitutes_each_placeholder() {
assert_eq!(subst("%f"), "chapter.tex");
assert_eq!(subst("%p"), "main.pdf");
assert_eq!(subst("%l"), "42");
assert_eq!(
subst("--synctex-forward=%l:1:%f"),
"--synctex-forward=42:1:chapter.tex"
);
assert_eq!(subst("file:%p#src:%l%f"), "file:main.pdf#src:42chapter.tex");
}
#[test]
fn fully_quoted_argument_is_verbatim() {
assert_eq!(subst("\"%f\""), "%f");
assert_eq!(subst("\"\""), "");
}
#[test]
fn partially_quoted_argument_still_substitutes() {
assert_eq!(subst("a\"%f\"b"), "a\"chapter.tex\"b");
assert_eq!(subst("\"%f"), "\"chapter.tex");
}
#[test]
fn double_percent_escapes() {
assert_eq!(subst("%%f"), "%f");
assert_eq!(subst("100%%"), "100%");
}
#[test]
fn unknown_placeholder_drops_the_percent() {
assert_eq!(subst("%z"), "z");
}
#[test]
fn trailing_percent_is_literal() {
assert_eq!(subst("50%"), "50%");
}
#[test]
fn placeholder_free_argument_is_identity() {
assert_eq!(subst(""), "");
assert_eq!(subst("--reuse-instance"), "--reuse-instance");
}
#[test]
fn viewer_args_substitutes_every_argument() {
let args: Vec<String> = ["--synctex-forward", "%l:1:%f", "%p"]
.iter()
.map(|s| (*s).to_owned())
.collect();
assert_eq!(
viewer_args(&args, &target()),
vec!["--synctex-forward", "42:1:chapter.tex", "main.pdf"]
);
}
#[test]
fn pdf_path_defaults_beside_the_root() {
let root = Path::new("proj").join("main.tex");
assert_eq!(
pdf_path(&root, &BuildConfig::default()),
Some(Path::new("proj").join("main.pdf"))
);
}
#[test]
fn pdf_path_handles_a_root_with_no_directory() {
assert_eq!(
pdf_path(Path::new("main.tex"), &BuildConfig::default()),
Some(PathBuf::from("main.pdf"))
);
}
#[test]
fn pdf_path_honors_relative_and_absolute_pdf_dir() {
let root = Path::new("proj").join("main.tex");
let relative = BuildConfig {
pdf_dir: Some(PathBuf::from("out")),
..BuildConfig::default()
};
assert_eq!(
pdf_path(&root, &relative),
Some(Path::new("proj").join("out").join("main.pdf"))
);
let absolute_dir = std::env::current_dir().expect("cwd").join("build");
let absolute = BuildConfig {
pdf_dir: Some(absolute_dir.clone()),
..BuildConfig::default()
};
assert_eq!(
pdf_path(&root, &absolute),
Some(absolute_dir.join("main.pdf"))
);
}
#[test]
fn pdf_path_honors_pdf_filename_and_appends_pdf() {
let root = Path::new("proj").join("main.tex");
for name in ["thesis.pdf", "thesis"] {
let build = BuildConfig {
pdf_filename: Some(name.to_owned()),
..BuildConfig::default()
};
assert_eq!(
pdf_path(&root, &build),
Some(Path::new("proj").join("thesis.pdf")),
"unexpected resolution for pdf-filename = `{name}`"
);
}
}
#[test]
fn status_codes_match_texlab() {
assert_eq!(ForwardSearchStatus::Success.code(), 0);
assert_eq!(ForwardSearchStatus::Error.code(), 1);
assert_eq!(ForwardSearchStatus::Failure.code(), 2);
assert_eq!(ForwardSearchStatus::Unconfigured.code(), 3);
assert_eq!(
serde_json::to_value(ForwardSearchStatus::Unconfigured.result()).expect("serialize"),
serde_json::json!({ "status": 3 })
);
}
}