diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index c86b3863a..fba1177f3 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -19,10 +19,8 @@ use typst::text::RawElem; use typst::visualize::Color; use unscanny::Scanner; -use crate::{ - analyze_expr, analyze_import, analyze_labels, named_items, plain_docs_sentence, - summarize_font_family, IdeWorld, -}; +use crate::utils::{plain_docs_sentence, summarize_font_family}; +use crate::{analyze_expr, analyze_import, analyze_labels, named_items, IdeWorld}; /// Autocomplete a cursor position in a source file. /// diff --git a/crates/typst-ide/src/lib.rs b/crates/typst-ide/src/lib.rs index 19a4f6434..038589c0d 100644 --- a/crates/typst-ide/src/lib.rs +++ b/crates/typst-ide/src/lib.rs @@ -6,6 +6,7 @@ mod definition; mod jump; mod matchers; mod tooltip; +mod utils; pub use self::analyze::{analyze_expr, analyze_import, analyze_labels}; pub use self::complete::{autocomplete, Completion, CompletionKind}; @@ -14,20 +15,17 @@ pub use self::jump::{jump_from_click, jump_from_cursor, Jump}; pub use self::matchers::{deref_target, named_items, DerefTarget, NamedItem}; pub use self::tooltip::{tooltip, Tooltip}; -use std::fmt::Write; - -use ecow::{eco_format, EcoString}; +use ecow::EcoString; use typst::syntax::package::PackageSpec; use typst::syntax::FileId; -use typst::text::{FontInfo, FontStyle}; use typst::World; /// Extends the `World` for IDE functionality. pub trait IdeWorld: World { - /// Turn into a normal [`World`]. + /// Turn this into a normal [`World`]. /// /// This is necessary because trait upcasting is experimental in Rust. - /// See: https://github.com/rust-lang/rust/issues/65991 + /// See . /// /// Implementors can simply return `self`. fn upcast(&self) -> &dyn World; @@ -51,266 +49,5 @@ pub trait IdeWorld: World { } } -/// Extract the first sentence of plain text of a piece of documentation. -/// -/// Removes Markdown formatting. -fn plain_docs_sentence(docs: &str) -> EcoString { - let mut s = unscanny::Scanner::new(docs); - let mut output = EcoString::new(); - let mut link = false; - while let Some(c) = s.eat() { - match c { - '`' => { - let mut raw = s.eat_until('`'); - if (raw.starts_with('{') && raw.ends_with('}')) - || (raw.starts_with('[') && raw.ends_with(']')) - { - raw = &raw[1..raw.len() - 1]; - } - - s.eat(); - output.push('`'); - output.push_str(raw); - output.push('`'); - } - '[' => link = true, - ']' if link => { - if s.eat_if('(') { - s.eat_until(')'); - s.eat(); - } else if s.eat_if('[') { - s.eat_until(']'); - s.eat(); - } - link = false - } - '*' | '_' => {} - '.' => { - output.push('.'); - break; - } - _ => output.push(c), - } - } - - output -} - -/// Create a short description of a font family. -fn summarize_font_family<'a>(variants: impl Iterator) -> EcoString { - let mut infos: Vec<_> = variants.collect(); - infos.sort_by_key(|info| info.variant); - - let mut has_italic = false; - let mut min_weight = u16::MAX; - let mut max_weight = 0; - for info in &infos { - let weight = info.variant.weight.to_number(); - has_italic |= info.variant.style == FontStyle::Italic; - min_weight = min_weight.min(weight); - max_weight = min_weight.max(weight); - } - - let count = infos.len(); - let mut detail = eco_format!("{count} variant{}.", if count == 1 { "" } else { "s" }); - - if min_weight == max_weight { - write!(detail, " Weight {min_weight}.").unwrap(); - } else { - write!(detail, " Weights {min_weight}–{max_weight}.").unwrap(); - } - - if has_italic { - detail.push_str(" Has italics."); - } - - detail -} - #[cfg(test)] -mod tests { - use std::collections::HashMap; - - 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. - pub struct TestWorld { - pub main: Source, - assets: HashMap, - sources: HashMap, - 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, - assets: HashMap::new(), - sources: HashMap::new(), - 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()); - self.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::from_static(data); - self.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 { - &self.base.library - } - - fn book(&self) -> &LazyHash { - &self.base.book - } - - fn main(&self) -> FileId { - self.main.id() - } - - fn source(&self, id: FileId) -> FileResult { - if id == self.main.id() { - Ok(self.main.clone()) - } else if let Some(source) = self.sources.get(&id) { - Ok(source.clone()) - } else { - Err(FileError::NotFound(id.vpath().as_rootless_path().into())) - } - } - - fn file(&self, id: FileId) -> FileResult { - match self.assets.get(&id) { - Some(bytes) => Ok(bytes.clone()), - None => Err(FileError::NotFound(id.vpath().as_rootless_path().into())), - } - } - - fn font(&self, index: usize) -> Option { - Some(self.base.fonts[index].clone()) - } - - fn today(&self, _: Option) -> Option { - None - } - } - - impl IdeWorld for TestWorld { - fn upcast(&self) -> &dyn World { - self - } - - fn files(&self) -> Vec { - std::iter::once(self.main.id()) - .chain(self.sources.keys().copied()) - .chain(self.assets.keys().copied()) - .collect() - } - - fn packages(&self) -> &[(PackageSpec, Option)] { - const LIST: &[(PackageSpec, Option)] = &[( - PackageSpec { - namespace: EcoString::inline("preview"), - name: EcoString::inline("example"), - version: PackageVersion { major: 0, minor: 1, patch: 0 }, - }, - None, - )]; - LIST - } - } - - /// Extra methods for [`Source`]. - pub trait SourceExt { - /// Negative cursors index from the back. - fn cursor(&self, cursor: isize) -> usize; - } - - impl SourceExt for Source { - fn cursor(&self, cursor: isize) -> usize { - if cursor < 0 { - self.len_bytes().checked_add_signed(cursor).unwrap() - } else { - cursor as usize - } - } - } - - /// Shared foundation of all test worlds. - struct TestBase { - library: LazyHash, - book: LazyHash, - fonts: Vec, - } - - impl Default for TestBase { - fn default() -> Self { - let fonts: Vec<_> = typst_assets::fonts() - .chain(typst_dev_assets::fonts()) - .flat_map(|data| Font::iter(Bytes::from_static(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 - } -} +mod tests; diff --git a/crates/typst-ide/src/tests.rs b/crates/typst-ide/src/tests.rs new file mode 100644 index 000000000..52b189fa0 --- /dev/null +++ b/crates/typst-ide/src/tests.rs @@ -0,0 +1,184 @@ +use std::collections::HashMap; + +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. +pub struct TestWorld { + pub main: Source, + assets: HashMap, + sources: HashMap, + 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, + assets: HashMap::new(), + sources: HashMap::new(), + 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()); + self.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::from_static(data); + self.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 { + &self.base.library + } + + fn book(&self) -> &LazyHash { + &self.base.book + } + + fn main(&self) -> FileId { + self.main.id() + } + + fn source(&self, id: FileId) -> FileResult { + if id == self.main.id() { + Ok(self.main.clone()) + } else if let Some(source) = self.sources.get(&id) { + Ok(source.clone()) + } else { + Err(FileError::NotFound(id.vpath().as_rootless_path().into())) + } + } + + fn file(&self, id: FileId) -> FileResult { + match self.assets.get(&id) { + Some(bytes) => Ok(bytes.clone()), + None => Err(FileError::NotFound(id.vpath().as_rootless_path().into())), + } + } + + fn font(&self, index: usize) -> Option { + Some(self.base.fonts[index].clone()) + } + + fn today(&self, _: Option) -> Option { + None + } +} + +impl IdeWorld for TestWorld { + fn upcast(&self) -> &dyn World { + self + } + + fn files(&self) -> Vec { + std::iter::once(self.main.id()) + .chain(self.sources.keys().copied()) + .chain(self.assets.keys().copied()) + .collect() + } + + fn packages(&self) -> &[(PackageSpec, Option)] { + const LIST: &[(PackageSpec, Option)] = &[( + PackageSpec { + namespace: EcoString::inline("preview"), + name: EcoString::inline("example"), + version: PackageVersion { major: 0, minor: 1, patch: 0 }, + }, + None, + )]; + LIST + } +} + +/// Extra methods for [`Source`]. +pub trait SourceExt { + /// Negative cursors index from the back. + fn cursor(&self, cursor: isize) -> usize; +} + +impl SourceExt for Source { + fn cursor(&self, cursor: isize) -> usize { + if cursor < 0 { + self.len_bytes().checked_add_signed(cursor).unwrap() + } else { + cursor as usize + } + } +} + +/// Shared foundation of all test worlds. +struct TestBase { + library: LazyHash, + book: LazyHash, + fonts: Vec, +} + +impl Default for TestBase { + fn default() -> Self { + let fonts: Vec<_> = typst_assets::fonts() + .chain(typst_dev_assets::fonts()) + .flat_map(|data| Font::iter(Bytes::from_static(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 +} diff --git a/crates/typst-ide/src/tooltip.rs b/crates/typst-ide/src/tooltip.rs index ade453e74..d62826522 100644 --- a/crates/typst-ide/src/tooltip.rs +++ b/crates/typst-ide/src/tooltip.rs @@ -11,10 +11,8 @@ use typst::syntax::{ast, LinkedNode, Side, Source, SyntaxKind}; use typst::utils::{round_with_precision, Numeric}; use typst_eval::CapturesVisitor; -use crate::{ - analyze_expr, analyze_import, analyze_labels, plain_docs_sentence, - summarize_font_family, IdeWorld, -}; +use crate::utils::{plain_docs_sentence, summarize_font_family}; +use crate::{analyze_expr, analyze_import, analyze_labels, IdeWorld}; /// Describe the item under the cursor. /// diff --git a/crates/typst-ide/src/utils.rs b/crates/typst-ide/src/utils.rs new file mode 100644 index 000000000..e5252d91d --- /dev/null +++ b/crates/typst-ide/src/utils.rs @@ -0,0 +1,82 @@ +use std::fmt::Write; + +use ecow::{eco_format, EcoString}; +use typst::text::{FontInfo, FontStyle}; + +/// Extract the first sentence of plain text of a piece of documentation. +/// +/// Removes Markdown formatting. +pub fn plain_docs_sentence(docs: &str) -> EcoString { + let mut s = unscanny::Scanner::new(docs); + let mut output = EcoString::new(); + let mut link = false; + while let Some(c) = s.eat() { + match c { + '`' => { + let mut raw = s.eat_until('`'); + if (raw.starts_with('{') && raw.ends_with('}')) + || (raw.starts_with('[') && raw.ends_with(']')) + { + raw = &raw[1..raw.len() - 1]; + } + + s.eat(); + output.push('`'); + output.push_str(raw); + output.push('`'); + } + '[' => link = true, + ']' if link => { + if s.eat_if('(') { + s.eat_until(')'); + s.eat(); + } else if s.eat_if('[') { + s.eat_until(']'); + s.eat(); + } + link = false + } + '*' | '_' => {} + '.' => { + output.push('.'); + break; + } + _ => output.push(c), + } + } + + output +} + +/// Create a short description of a font family. +pub fn summarize_font_family<'a>( + variants: impl Iterator, +) -> EcoString { + let mut infos: Vec<_> = variants.collect(); + infos.sort_by_key(|info| info.variant); + + let mut has_italic = false; + let mut min_weight = u16::MAX; + let mut max_weight = 0; + for info in &infos { + let weight = info.variant.weight.to_number(); + has_italic |= info.variant.style == FontStyle::Italic; + min_weight = min_weight.min(weight); + max_weight = min_weight.max(weight); + } + + let count = infos.len(); + let mut detail = eco_format!("{count} variant{}.", if count == 1 { "" } else { "s" }); + + if min_weight == max_weight { + write!(detail, " Weight {min_weight}.").unwrap(); + } else { + write!(detail, " Weights {min_weight}–{max_weight}.").unwrap(); + } + + if has_italic { + detail.push_str(" Has italics."); + } + + detail +}