236 lines
6.7 KiB
Rust

use std::borrow::Borrow;
use std::collections::HashMap;
use std::sync::Arc;
use ecow::EcoString;
use typst::diag::{FileError, FileResult};
use typst::foundations::{Bytes, Datetime, Smart};
use typst::layout::{Abs, Margin, PageElem};
use typst::syntax::package::{PackageSpec, PackageVersion};
use typst::syntax::{FileId, Source, VirtualPath};
use typst::text::{Font, FontBook, TextElem, TextSize};
use typst::utils::{singleton, LazyHash};
use typst::{Library, World};
use crate::IdeWorld;
/// A world for IDE testing.
#[derive(Clone)]
pub struct TestWorld {
pub main: Source,
files: Arc<TestFiles>,
base: &'static TestBase,
}
impl TestWorld {
/// Create a new world for a single test.
///
/// This is cheap because the shared base for all test runs is lazily
/// initialized just once.
pub fn new(text: &str) -> Self {
let main = Source::new(Self::main_id(), text.into());
Self {
main,
files: Arc::new(TestFiles::default()),
base: singleton!(TestBase, TestBase::default()),
}
}
/// Add an additional source file to the test world.
pub fn with_source(mut self, path: &str, text: &str) -> Self {
let id = FileId::new(None, VirtualPath::new(path));
let source = Source::new(id, text.into());
Arc::make_mut(&mut self.files).sources.insert(id, source);
self
}
/// Add an additional asset file to the test world.
#[track_caller]
pub fn with_asset(self, filename: &str) -> Self {
self.with_asset_at(filename, filename)
}
/// Add an additional asset file to the test world.
#[track_caller]
pub fn with_asset_at(mut self, path: &str, filename: &str) -> Self {
let id = FileId::new(None, VirtualPath::new(path));
let data = typst_dev_assets::get_by_name(filename).unwrap();
let bytes = Bytes::new(data);
Arc::make_mut(&mut self.files).assets.insert(id, bytes);
self
}
/// The ID of the main file in a `TestWorld`.
pub fn main_id() -> FileId {
*singleton!(FileId, FileId::new(None, VirtualPath::new("main.typ")))
}
}
impl World for TestWorld {
fn library(&self) -> &LazyHash<Library> {
&self.base.library
}
fn book(&self) -> &LazyHash<FontBook> {
&self.base.book
}
fn main(&self) -> FileId {
self.main.id()
}
fn source(&self, id: FileId) -> FileResult<Source> {
if id == self.main.id() {
Ok(self.main.clone())
} else if let Some(source) = self.files.sources.get(&id) {
Ok(source.clone())
} else {
Err(FileError::NotFound(id.vpath().as_rootless_path().into()))
}
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
match self.files.assets.get(&id) {
Some(bytes) => Ok(bytes.clone()),
None => Err(FileError::NotFound(id.vpath().as_rootless_path().into())),
}
}
fn font(&self, index: usize) -> Option<Font> {
Some(self.base.fonts[index].clone())
}
fn today(&self, _: Option<i64>) -> Option<Datetime> {
None
}
}
impl IdeWorld for TestWorld {
fn upcast(&self) -> &dyn World {
self
}
fn files(&self) -> Vec<FileId> {
std::iter::once(self.main.id())
.chain(self.files.sources.keys().copied())
.chain(self.files.assets.keys().copied())
.collect()
}
fn packages(&self) -> &[(PackageSpec, Option<EcoString>)] {
const LIST: &[(PackageSpec, Option<EcoString>)] = &[(
PackageSpec {
// NOTE: This literal, `"preview"`, should match the const, `DEFAULT_NAMESPACE`,
// defined in `crates/typst-kit/src/package.rs`. However, we should always use the
// literal here, not `DEFAULT_NAMESPACE`, so that this test fails if its value
// changes in an unexpected way.
namespace: EcoString::inline("preview"),
name: EcoString::inline("example"),
version: PackageVersion { major: 0, minor: 1, patch: 0 },
},
None,
)];
LIST
}
}
/// Test-specific files.
#[derive(Default, Clone)]
struct TestFiles {
assets: HashMap<FileId, Bytes>,
sources: HashMap<FileId, Source>,
}
/// Shared foundation of all test worlds.
struct TestBase {
library: LazyHash<Library>,
book: LazyHash<FontBook>,
fonts: Vec<Font>,
}
impl Default for TestBase {
fn default() -> Self {
let fonts: Vec<_> = typst_assets::fonts()
.chain(typst_dev_assets::fonts())
.flat_map(|data| Font::iter(Bytes::new(data)))
.collect();
Self {
library: LazyHash::new(library()),
book: LazyHash::new(FontBook::from_fonts(&fonts)),
fonts,
}
}
}
/// The extended standard library for testing.
fn library() -> Library {
// Set page width to 120pt with 10pt margins, so that the inner page is
// exactly 100pt wide. Page height is unbounded and font size is 10pt so
// that it multiplies to nice round numbers.
let mut lib = typst::Library::default();
lib.styles
.set(PageElem::set_width(Smart::Custom(Abs::pt(120.0).into())));
lib.styles.set(PageElem::set_height(Smart::Auto));
lib.styles.set(PageElem::set_margin(Margin::splat(Some(Smart::Custom(
Abs::pt(10.0).into(),
)))));
lib.styles.set(TextElem::set_size(TextSize(Abs::pt(10.0).into())));
lib
}
/// The input to a test: Either just a string or a full `TestWorld`.
pub trait WorldLike {
type World: Borrow<TestWorld>;
fn acquire(self) -> Self::World;
}
impl<'a> WorldLike for &'a TestWorld {
type World = &'a TestWorld;
fn acquire(self) -> Self::World {
self
}
}
impl WorldLike for &str {
type World = TestWorld;
fn acquire(self) -> Self::World {
TestWorld::new(self)
}
}
/// Specifies a position in a file for a test.
pub trait FilePos {
fn resolve(self, world: &TestWorld) -> (Source, usize);
}
impl FilePos for isize {
#[track_caller]
fn resolve(self, world: &TestWorld) -> (Source, usize) {
(world.main.clone(), cursor(&world.main, self))
}
}
impl FilePos for (&str, isize) {
#[track_caller]
fn resolve(self, world: &TestWorld) -> (Source, usize) {
let id = FileId::new(None, VirtualPath::new(self.0));
let source = world.source(id).unwrap();
let cursor = cursor(&source, self.1);
(source, cursor)
}
}
/// Resolve a signed index (negative from the back) to a unsigned index.
#[track_caller]
fn cursor(source: &Source, cursor: isize) -> usize {
if cursor < 0 {
source.len_bytes().checked_add_signed(cursor + 1).unwrap()
} else {
cursor as usize
}
}