feat: profile and visualize coverage of the current document (#1490)

* feat: draft

* feat: run coverage command in vscode

* feat: create and move location crate

* feat: run and visualize coverage

* feat: l10n
This commit is contained in:
Myriad-Dreamin 2025-03-15 11:49:51 +08:00 committed by GitHub
parent 99900b2c76
commit c96ea6d77f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1668 additions and 471 deletions

19
Cargo.lock generated
View file

@ -4027,6 +4027,7 @@ dependencies = [
"temp-env",
"tinymist-assets 0.13.8 (registry+https://github.com/rust-lang/crates.io-index)",
"tinymist-core",
"tinymist-debug",
"tinymist-l10n",
"tinymist-project",
"tinymist-query",
@ -4060,6 +4061,7 @@ version = "0.13.2"
dependencies = [
"ecow",
"insta",
"log",
"lsp-types",
"regex",
"serde",
@ -4098,6 +4100,23 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "tinymist-debug"
version = "0.13.8"
dependencies = [
"base64",
"comemo",
"insta",
"parking_lot",
"serde",
"serde_json",
"tinymist-analysis",
"tinymist-std",
"tinymist-world",
"typst",
"typst-library",
]
[[package]]
name = "tinymist-derive"
version = "0.13.2"

View file

@ -134,6 +134,7 @@ reflexo-vec2svg = { version = "=0.5.5-rc7" }
typst = "0.13.0"
typst-html = "0.13.0"
typst-library = "0.13.0"
typst-timing = "0.13.0"
typst-svg = "0.13.0"
typst-render = "0.13.0"
@ -186,6 +187,7 @@ tinymist-analysis = { path = "./crates/tinymist-analysis/", version = "0.13.2" }
typst-shim = { path = "./crates/typst-shim", version = "0.13.2" }
tinymist-core = { path = "./crates/tinymist-core/", version = "0.13.8", default-features = false }
tinymist-debug = { path = "./crates/tinymist-debug/", version = "0.13.8" }
tinymist = { path = "./crates/tinymist/", version = "0.13.8" }
tinymist-l10n = { path = "./crates/tinymist-l10n/", version = "0.13.8" }
tinymist-query = { path = "./crates/tinymist-query/", version = "0.13.8" }
@ -258,6 +260,7 @@ extend-exclude = ["/.git", "fixtures"]
#
# A regular build MUST use `tag` or `rev` to specify the version of the patched crate to ensure stability.
typst = { git = "https://github.com/Myriad-Dreamin/typst.git", tag = "tinymist/v0.13.2" }
typst-library = { git = "https://github.com/Myriad-Dreamin/typst.git", tag = "tinymist/v0.13.2" }
typst-html = { git = "https://github.com/Myriad-Dreamin/typst.git", tag = "tinymist/v0.13.2" }
typst-timing = { git = "https://github.com/Myriad-Dreamin/typst.git", tag = "tinymist/v0.13.2" }
typst-svg = { git = "https://github.com/Myriad-Dreamin/typst.git", tag = "tinymist/v0.13.2" }

View file

@ -19,6 +19,7 @@ serde.workspace = true
strum.workspace = true
toml.workspace = true
typst.workspace = true
log.workspace = true
[dev-dependencies]
insta.workspace = true

View file

@ -1,6 +1,7 @@
//! Tinymist Analysis
pub mod debug_loc;
pub mod location;
mod prelude;
pub mod syntax;

View file

@ -0,0 +1,364 @@
//! Conversions between Typst and LSP locations
use std::cmp::Ordering;
use std::ops::Range;
use typst::syntax::Source;
/// An LSP Position encoded by [`PositionEncoding`].
type LspPosition = lsp_types::Position;
/// An LSP range encoded by [`PositionEncoding`].
type LspRange = lsp_types::Range;
/// What counts as "1 character" for string indexing. We should always prefer
/// UTF-8, but support UTF-16 as long as it is standard. For more background on
/// encodings and LSP, try ["The bottom emoji breaks rust-analyzer"](https://fasterthanli.me/articles/the-bottom-emoji-breaks-rust-analyzer),
/// a well-written article on the topic.
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default)]
pub enum PositionEncoding {
/// "1 character" means "1 UTF-16 code unit"
///
/// This is the only required encoding for LSPs to support, but it's not a
/// natural one (unless you're working in JS). Prefer UTF-8, and refer
/// to the article linked in the `PositionEncoding` docs for more
/// background.
#[default]
Utf16,
/// "1 character" means "1 byte"
Utf8,
}
impl From<PositionEncoding> for lsp_types::PositionEncodingKind {
fn from(position_encoding: PositionEncoding) -> Self {
match position_encoding {
PositionEncoding::Utf16 => Self::UTF16,
PositionEncoding::Utf8 => Self::UTF8,
}
}
}
/// Convert an LSP position to a Typst position.
pub fn to_typst_position(
lsp_position: LspPosition,
lsp_position_encoding: PositionEncoding,
typst_source: &Source,
) -> Option<usize> {
let lines = typst_source.len_lines() as u32;
'bound_checking: {
let should_warning = match lsp_position.line.cmp(&lines) {
Ordering::Greater => true,
Ordering::Equal => lsp_position.character > 0,
Ordering::Less if lsp_position.line + 1 == lines => {
let last_line_offset = typst_source.line_to_byte(lines as usize - 1)?;
let last_line_chars = &typst_source.text()[last_line_offset..];
let len = match lsp_position_encoding {
PositionEncoding::Utf8 => last_line_chars.len(),
PositionEncoding::Utf16 => {
last_line_chars.chars().map(char::len_utf16).sum::<usize>()
}
};
match lsp_position.character.cmp(&(len as u32)) {
Ordering::Less => break 'bound_checking,
Ordering::Greater => true,
Ordering::Equal => false,
}
}
Ordering::Less => break 'bound_checking,
};
if should_warning {
log::warn!(
"LSP position is out of bounds: {:?}, while only {:?} lines and {:?} characters at the end.",
lsp_position, typst_source.len_lines(), typst_source.line_to_range(typst_source.len_lines() - 1),
);
}
return Some(typst_source.len_bytes());
}
match lsp_position_encoding {
PositionEncoding::Utf8 => {
let line_index = lsp_position.line as usize;
let column_index = lsp_position.character as usize;
typst_source.line_column_to_byte(line_index, column_index)
}
PositionEncoding::Utf16 => {
// We have a line number and a UTF-16 offset into that line. We want a byte
// offset into the file.
//
// Typst's `Source` provides several UTF-16 methods:
// - `len_utf16` for the length of the file
// - `byte_to_utf16` to convert a byte offset from the start of the file to a
// UTF-16 offset from the start of the file
// - `utf16_to_byte` to do the opposite of `byte_to_utf16`
//
// Unfortunately, none of these address our needs well, so we do some math
// instead. This is not the fastest possible implementation, but
// it's the most reasonable without access to the internal state
// of `Source`.
// TODO: Typst's `Source` could easily provide an implementation of the method
// we need here. Submit a PR against `typst` to add it, then
// update this if/when merged.
let line_index = lsp_position.line as usize;
let utf16_offset_in_line = lsp_position.character as usize;
let byte_line_offset = typst_source.line_to_byte(line_index)?;
let utf16_line_offset = typst_source.byte_to_utf16(byte_line_offset)?;
let utf16_offset = utf16_line_offset + utf16_offset_in_line;
typst_source.utf16_to_byte(utf16_offset)
}
}
}
/// Convert a Typst position to an LSP position.
pub fn to_lsp_position(
typst_offset: usize,
lsp_position_encoding: PositionEncoding,
typst_source: &Source,
) -> LspPosition {
if typst_offset > typst_source.len_bytes() {
return LspPosition::new(typst_source.len_lines() as u32, 0);
}
let line_index = typst_source.byte_to_line(typst_offset).unwrap();
let column_index = typst_source.byte_to_column(typst_offset).unwrap();
let lsp_line = line_index as u32;
let lsp_column = match lsp_position_encoding {
PositionEncoding::Utf8 => column_index as u32,
PositionEncoding::Utf16 => {
// See the implementation of `position_to_offset` for discussion
// relevant to this function.
// TODO: Typst's `Source` could easily provide an implementation of the method
// we need here. Submit a PR to `typst` to add it, then update
// this if/when merged.
let utf16_offset = typst_source.byte_to_utf16(typst_offset).unwrap();
let byte_line_offset = typst_source.line_to_byte(line_index).unwrap();
let utf16_line_offset = typst_source.byte_to_utf16(byte_line_offset).unwrap();
let utf16_column_offset = utf16_offset - utf16_line_offset;
utf16_column_offset as u32
}
};
LspPosition::new(lsp_line, lsp_column)
}
/// Convert an LSP range to a Typst range.
pub fn to_typst_range(
lsp_range: LspRange,
lsp_position_encoding: PositionEncoding,
source: &Source,
) -> Option<Range<usize>> {
let lsp_start = lsp_range.start;
let typst_start = to_typst_position(lsp_start, lsp_position_encoding, source)?;
let lsp_end = lsp_range.end;
let typst_end = to_typst_position(lsp_end, lsp_position_encoding, source)?;
Some(Range {
start: typst_start,
end: typst_end,
})
}
/// Convert a Typst range to an LSP range.
pub fn to_lsp_range(
typst_range: Range<usize>,
typst_source: &Source,
lsp_position_encoding: PositionEncoding,
) -> LspRange {
let typst_start = typst_range.start;
let lsp_start = to_lsp_position(typst_start, lsp_position_encoding, typst_source);
let typst_end = typst_range.end;
let lsp_end = to_lsp_position(typst_end, lsp_position_encoding, typst_source);
LspRange::new(lsp_start, lsp_end)
}
#[cfg(test)]
mod test {
use lsp_types::Position;
use super::*;
const ENCODING_TEST_STRING: &str = "test 🥺 test";
#[test]
fn issue_14_invalid_range() {
let source = Source::detached("#set page(height: 2cm)");
let rng = LspRange {
start: LspPosition {
line: 0,
character: 22,
},
// EOF
end: LspPosition {
line: 1,
character: 0,
},
};
let res = to_typst_range(rng, PositionEncoding::Utf16, &source).unwrap();
assert_eq!(res, 22..22);
}
#[test]
fn issue_14_invalid_range_2() {
let source = Source::detached(
r"#let f(a) = {
a
}
",
);
let rng = LspRange {
start: LspPosition {
line: 2,
character: 1,
},
// EOF
end: LspPosition {
line: 3,
character: 0,
},
};
let res = to_typst_range(rng, PositionEncoding::Utf16, &source).unwrap();
assert_eq!(res, 19..source.len_bytes());
// EOF
let rng = LspRange {
start: LspPosition {
line: 3,
character: 1,
},
end: LspPosition {
line: 4,
character: 0,
},
};
let res = to_typst_range(rng, PositionEncoding::Utf16, &source).unwrap();
assert_eq!(res, source.len_bytes()..source.len_bytes());
for line in 0..=5 {
for character in 0..2 {
let off = to_typst_position(
Position { line, character },
PositionEncoding::Utf16,
&source,
);
assert!(off.is_some(), "line: {line}, character: {character}");
}
}
}
#[test]
fn overflow_offset_to_position() {
let source = Source::detached("test");
let offset = source.len_bytes();
let position = to_lsp_position(offset, PositionEncoding::Utf16, &source);
assert_eq!(
position,
LspPosition {
line: 0,
character: 4
}
);
let offset = source.len_bytes() + 1;
let position = to_lsp_position(offset, PositionEncoding::Utf16, &source);
assert_eq!(
position,
LspPosition {
line: 1,
character: 0
}
);
}
#[test]
fn utf16_position_to_utf8_offset() {
let source = Source::detached(ENCODING_TEST_STRING);
let start = LspPosition {
line: 0,
character: 0,
};
let emoji = LspPosition {
line: 0,
character: 5,
};
let post_emoji = LspPosition {
line: 0,
character: 7,
};
let end = LspPosition {
line: 0,
character: 12,
};
let start_offset = to_typst_position(start, PositionEncoding::Utf16, &source).unwrap();
let start_actual = 0;
let emoji_offset = to_typst_position(emoji, PositionEncoding::Utf16, &source).unwrap();
let emoji_actual = 5;
let post_emoji_offset =
to_typst_position(post_emoji, PositionEncoding::Utf16, &source).unwrap();
let post_emoji_actual = 9;
let end_offset = to_typst_position(end, PositionEncoding::Utf16, &source).unwrap();
let end_actual = 14;
assert_eq!(start_offset, start_actual);
assert_eq!(emoji_offset, emoji_actual);
assert_eq!(post_emoji_offset, post_emoji_actual);
assert_eq!(end_offset, end_actual);
}
#[test]
fn utf8_offset_to_utf16_position() {
let source = Source::detached(ENCODING_TEST_STRING);
let start = 0;
let emoji = 5;
let post_emoji = 9;
let end = 14;
let start_position = LspPosition {
line: 0,
character: 0,
};
let start_actual = to_lsp_position(start, PositionEncoding::Utf16, &source);
let emoji_position = LspPosition {
line: 0,
character: 5,
};
let emoji_actual = to_lsp_position(emoji, PositionEncoding::Utf16, &source);
let post_emoji_position = LspPosition {
line: 0,
character: 7,
};
let post_emoji_actual = to_lsp_position(post_emoji, PositionEncoding::Utf16, &source);
let end_position = LspPosition {
line: 0,
character: 12,
};
let end_actual = to_lsp_position(end, PositionEncoding::Utf16, &source);
assert_eq!(start_position, start_actual);
assert_eq!(emoji_position, emoji_actual);
assert_eq!(post_emoji_position, post_emoji_actual);
assert_eq!(end_position, end_actual);
}
}

View file

@ -0,0 +1,30 @@
[package]
name = "tinymist-debug"
description = "Tinymist debug support for Typst."
categories = ["compilers"]
keywords = ["api", "language", "typst"]
authors.workspace = true
version.workspace = true
license.workspace = true
edition.workspace = true
homepage.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies]
typst-library.workspace = true
typst.workspace = true
tinymist-std.workspace = true
tinymist-analysis.workspace = true
tinymist-world = { workspace = true, features = ["system"] }
parking_lot.workspace = true
comemo.workspace = true
serde.workspace = true
serde_json.workspace = true
base64.workspace = true
[dev-dependencies]
insta.workspace = true
[lints]
workspace = true

View file

@ -0,0 +1,367 @@
//! Tinymist coverage support for Typst.
use std::sync::{Arc, LazyLock};
use parking_lot::Mutex;
use tinymist_std::hash::FxHashMap;
use tinymist_world::vfs::FileId;
use typst::diag::FileResult;
use typst::foundations::func;
use typst::syntax::ast::AstNode;
use typst::syntax::{ast, Source, Span, SyntaxNode};
use crate::instrument::Instrumenter;
#[derive(Default)]
pub struct CoverageInstrumenter {
/// The coverage map.
pub map: Mutex<FxHashMap<FileId, Arc<InstrumentMeta>>>,
}
impl Instrumenter for CoverageInstrumenter {
fn instrument(&self, _source: Source) -> FileResult<Source> {
let (new, meta) = instrument_coverage(_source)?;
let region = CovRegion {
hits: Arc::new(Mutex::new(vec![0; meta.meta.len()])),
};
let mut map = self.map.lock();
map.insert(new.id(), meta);
let mut cov_map = COVERAGE_MAP.lock();
cov_map.regions.insert(new.id(), region);
Ok(new)
}
}
/// The coverage map.
#[derive(Default)]
pub struct CoverageMap {
last_hit: Option<(FileId, CovRegion)>,
/// The coverage map.
pub regions: FxHashMap<FileId, CovRegion>,
}
/// The coverage region
#[derive(Default, Clone)]
pub struct CovRegion {
/// The hits
pub hits: Arc<Mutex<Vec<u8>>>,
}
pub static COVERAGE_LOCK: LazyLock<Mutex<()>> = LazyLock::new(Mutex::default);
pub static COVERAGE_MAP: LazyLock<Mutex<CoverageMap>> = LazyLock::new(Mutex::default);
#[func(name = "__cov_pc", title = "Coverage function")]
pub fn __cov_pc(span: Span, pc: i64) {
let Some(fid) = span.id() else {
return;
};
let mut map = COVERAGE_MAP.lock();
if let Some(last_hit) = map.last_hit.as_ref() {
if last_hit.0 == fid {
let mut hits = last_hit.1.hits.lock();
let c = &mut hits[pc as usize];
*c = c.saturating_add(1);
return;
}
}
let region = map.regions.entry(fid).or_default();
{
let mut hits = region.hits.lock();
let c = &mut hits[pc as usize];
*c = c.saturating_add(1);
}
map.last_hit = Some((fid, region.clone()));
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
OpenBrace,
CloseBrace,
Functor,
}
#[derive(Default)]
pub struct InstrumentMeta {
pub meta: Vec<(Span, Kind)>,
}
#[comemo::memoize]
fn instrument_coverage(source: Source) -> FileResult<(Source, Arc<InstrumentMeta>)> {
let node = source.root();
let mut worker = InstrumentWorker {
meta: InstrumentMeta::default(),
instrumented: String::new(),
};
worker.visit_node(node);
let new_source: Source = Source::new(source.id(), worker.instrumented);
Ok((new_source, Arc::new(worker.meta)))
}
struct InstrumentWorker {
meta: InstrumentMeta,
instrumented: String,
}
impl InstrumentWorker {
fn instrument_block_child(&mut self, container: &SyntaxNode, b1: Span, b2: Span) {
for child in container.children() {
if b1 == child.span() || b2 == child.span() {
self.instrument_block(child);
} else {
self.visit_node(child);
}
}
}
fn visit_node(&mut self, node: &SyntaxNode) {
if let Some(expr) = node.cast::<ast::Expr>() {
match expr {
ast::Expr::Code(..) => {
self.instrument_block(node);
return;
}
ast::Expr::While(while_expr) => {
self.instrument_block_child(node, while_expr.body().span(), Span::detached());
return;
}
ast::Expr::For(for_expr) => {
self.instrument_block_child(node, for_expr.body().span(), Span::detached());
return;
}
ast::Expr::Conditional(cond_expr) => {
self.instrument_block_child(
node,
cond_expr.if_body().span(),
cond_expr.else_body().unwrap_or_default().span(),
);
return;
}
ast::Expr::Closure(closure) => {
self.instrument_block_child(node, closure.body().span(), Span::detached());
return;
}
ast::Expr::Show(show_rule) => {
let transform = show_rule.transform().to_untyped().span();
for child in node.children() {
if transform == child.span() {
self.instrument_functor(child);
} else {
self.visit_node(child);
}
}
return;
}
ast::Expr::Text(..)
| ast::Expr::Space(..)
| ast::Expr::Linebreak(..)
| ast::Expr::Parbreak(..)
| ast::Expr::Escape(..)
| ast::Expr::Shorthand(..)
| ast::Expr::SmartQuote(..)
| ast::Expr::Strong(..)
| ast::Expr::Emph(..)
| ast::Expr::Raw(..)
| ast::Expr::Link(..)
| ast::Expr::Label(..)
| ast::Expr::Ref(..)
| ast::Expr::Heading(..)
| ast::Expr::List(..)
| ast::Expr::Enum(..)
| ast::Expr::Term(..)
| ast::Expr::Equation(..)
| ast::Expr::Math(..)
| ast::Expr::MathText(..)
| ast::Expr::MathIdent(..)
| ast::Expr::MathShorthand(..)
| ast::Expr::MathAlignPoint(..)
| ast::Expr::MathDelimited(..)
| ast::Expr::MathAttach(..)
| ast::Expr::MathPrimes(..)
| ast::Expr::MathFrac(..)
| ast::Expr::MathRoot(..)
| ast::Expr::Ident(..)
| ast::Expr::None(..)
| ast::Expr::Auto(..)
| ast::Expr::Bool(..)
| ast::Expr::Int(..)
| ast::Expr::Float(..)
| ast::Expr::Numeric(..)
| ast::Expr::Str(..)
| ast::Expr::Content(..)
| ast::Expr::Parenthesized(..)
| ast::Expr::Array(..)
| ast::Expr::Dict(..)
| ast::Expr::Unary(..)
| ast::Expr::Binary(..)
| ast::Expr::FieldAccess(..)
| ast::Expr::FuncCall(..)
| ast::Expr::Let(..)
| ast::Expr::DestructAssign(..)
| ast::Expr::Set(..)
| ast::Expr::Contextual(..)
| ast::Expr::Import(..)
| ast::Expr::Include(..)
| ast::Expr::Break(..)
| ast::Expr::Continue(..)
| ast::Expr::Return(..) => {}
}
}
self.visit_node_fallback(node);
}
fn visit_node_fallback(&mut self, node: &SyntaxNode) {
let txt = node.text();
if !txt.is_empty() {
self.instrumented.push_str(txt);
}
for child in node.children() {
self.visit_node(child);
}
}
fn make_cov(&mut self, span: Span, kind: Kind) {
let it = self.meta.meta.len();
self.meta.meta.push((span, kind));
self.instrumented.push_str("__cov_pc(");
self.instrumented.push_str(&it.to_string());
self.instrumented.push_str(");\n");
}
fn instrument_block(&mut self, child: &SyntaxNode) {
self.instrumented.push_str("{\n");
let (first, last) = {
let mut children = child.children();
let first = children
.next()
.map(|s| s.span())
.unwrap_or_else(Span::detached);
let last = children
.last()
.map(|s| s.span())
.unwrap_or_else(Span::detached);
(first, last)
};
self.make_cov(first, Kind::OpenBrace);
self.visit_node_fallback(child);
self.instrumented.push('\n');
self.make_cov(last, Kind::CloseBrace);
self.instrumented.push_str("}\n");
}
fn instrument_functor(&mut self, child: &SyntaxNode) {
self.instrumented.push_str("{\nlet __cov_functor = ");
let s = child.span();
self.visit_node_fallback(child);
self.instrumented.push_str("\n__it => {");
self.make_cov(s, Kind::Functor);
self.instrumented.push_str("__cov_functor(__it); } }\n");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn instr(input: &str) -> String {
let source = Source::detached(input);
let (new, _meta) = instrument_coverage(source).unwrap();
new.text().to_string()
}
#[test]
fn test_physica_vector() {
let instrumented = instr(include_str!("fixtures/instr_coverage/physica_vector.typ"));
insta::assert_snapshot!(instrumented, @r#"
// A show rule, should be used like:
// #show: super-plus-as-dagger
// U^+U = U U^+ = I
// or in scope:
// #[
// #show: super-plus-as-dagger
// U^+U = U U^+ = I
// ]
#let super-plus-as-dagger(document) = {
__cov_pc(0);
{
show math.attach: {
let __cov_functor = elem => {
__cov_pc(1);
{
if __eligible(elem.base) and elem.at("t", default: none) == [+] {
__cov_pc(2);
{
$attach(elem.base, t: dagger, b: elem.at("b", default: #none))$
}
__cov_pc(3);
}
else {
__cov_pc(4);
{
elem
}
__cov_pc(5);
}
}
__cov_pc(6);
}
__it => {__cov_pc(7);
__cov_functor(__it); } }
document
}
__cov_pc(8);
}
"#);
}
#[test]
fn test_playground() {
let instrumented = instr(include_str!("fixtures/instr_coverage/playground.typ"));
insta::assert_snapshot!(instrumented, @"");
}
#[test]
fn test_instrument_coverage() {
let source = Source::detached("#let a = 1;");
let (new, _meta) = instrument_coverage(source).unwrap();
insta::assert_snapshot!(new.text(), @"#let a = 1;");
}
#[test]
fn test_instrument_coverage_nested() {
let source = Source::detached("#let a = {1};");
let (new, _meta) = instrument_coverage(source).unwrap();
insta::assert_snapshot!(new.text(), @r"
#let a = {
__cov_pc(0);
{1}
__cov_pc(1);
}
;
");
}
#[test]
fn test_instrument_coverage_functor() {
let source = Source::detached("#show: main");
let (new, _meta) = instrument_coverage(source).unwrap();
insta::assert_snapshot!(new.text(), @r"
#show: {
let __cov_functor = main
__it => {__cov_pc(0);
__cov_functor(__it); } }
");
}
}

View file

@ -0,0 +1,20 @@
// A show rule, should be used like:
// #show: super-plus-as-dagger
// U^+U = U U^+ = I
// or in scope:
// #[
// #show: super-plus-as-dagger
// U^+U = U U^+ = I
// ]
#let super-plus-as-dagger(document) = {
show math.attach: elem => {
if __eligible(elem.base) and elem.at("t", default: none) == [+] {
$attach(elem.base, t: dagger, b: elem.at("b", default: #none))$
} else {
elem
}
}
document
}

View file

@ -0,0 +1,72 @@
//! Tinymist instrument support for Typst.
use std::sync::Arc;
use parking_lot::Mutex;
use tinymist_std::hash::FxHashMap;
use tinymist_world::vfs::PathResolution;
use tinymist_world::SourceWorld;
use tinymist_world::{vfs::FileId, CompilerFeat, CompilerWorld};
use typst::diag::FileResult;
use typst::foundations::{Bytes, Datetime};
use typst::syntax::Source;
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::Library;
pub trait Instrumenter: Send + Sync {
fn instrument(&self, source: Source) -> FileResult<Source>;
}
pub struct InstrumentWorld<'a, F: CompilerFeat, I> {
pub base: &'a CompilerWorld<F>,
pub library: Arc<LazyHash<Library>>,
pub instr: I,
pub instrumented: Mutex<FxHashMap<FileId, FileResult<Source>>>,
}
impl<F: CompilerFeat, I: Instrumenter> typst::World for InstrumentWorld<'_, F, I>
where
I:,
{
fn library(&self) -> &LazyHash<Library> {
&self.library
}
fn book(&self) -> &LazyHash<FontBook> {
self.base.book()
}
fn main(&self) -> FileId {
self.base.main()
}
fn source(&self, id: FileId) -> FileResult<Source> {
let mut instrumented = self.instrumented.lock();
if let Some(source) = instrumented.get(&id) {
return source.clone();
}
let source = self.base.source(id).and_then(|s| self.instr.instrument(s));
instrumented.insert(id, source.clone());
source
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
self.base.file(id)
}
fn font(&self, index: usize) -> Option<Font> {
self.base.font(index)
}
fn today(&self, offset: Option<i64>) -> Option<Datetime> {
self.base.today(offset)
}
}
impl<F: CompilerFeat, I: Instrumenter> SourceWorld for InstrumentWorld<'_, F, I> {
fn path_for_id(&self, id: FileId) -> FileResult<PathResolution> {
self.base.path_for_id(id)
}
}

View file

@ -0,0 +1,145 @@
//! Tinymist coverage support for Typst.
mod cov;
mod instrument;
use std::collections::HashMap;
use std::ops::DerefMut;
use std::sync::Arc;
use parking_lot::Mutex;
use tinymist_analysis::location::PositionEncoding;
use tinymist_std::debug_loc::LspRange;
use tinymist_std::{error::prelude::*, hash::FxHashMap};
use tinymist_world::package::PackageSpec;
use tinymist_world::{print_diagnostics, CompilerFeat, CompilerWorld};
use typst::diag::EcoString;
use typst::syntax::package::PackageVersion;
use typst::syntax::FileId;
use typst::utils::LazyHash;
use typst::{Library, World, WorldExt};
use cov::*;
use instrument::InstrumentWorld;
/// The coverage result.
pub struct CoverageResult {
/// The coverage meta.
pub meta: FxHashMap<FileId, Arc<InstrumentMeta>>,
/// The coverage map.
pub regions: FxHashMap<FileId, CovRegion>,
}
impl CoverageResult {
/// Converts the coverage result to JSON.
pub fn to_json<F: CompilerFeat>(&self, w: &CompilerWorld<F>) -> serde_json::Value {
let lsp_position_encoding = PositionEncoding::Utf16;
let mut result = VscodeCoverage::new();
for (file_id, region) in &self.regions {
let file_path = w
.path_for_id(*file_id)
.unwrap()
.as_path()
.to_str()
.unwrap()
.to_string();
let mut details = vec![];
let meta = self.meta.get(file_id).unwrap();
let Ok(typst_source) = w.source(*file_id) else {
continue;
};
let hits = region.hits.lock();
for (idx, (span, _kind)) in meta.meta.iter().enumerate() {
let Some(typst_range) = w.range(*span) else {
continue;
};
let rng = tinymist_analysis::location::to_lsp_range(
typst_range,
&typst_source,
lsp_position_encoding,
);
details.push(VscodeFileCoverageDetail {
executed: hits[idx] > 0,
location: rng,
});
}
result.insert(file_path, details);
}
serde_json::to_value(result).unwrap()
}
}
/// Collects the coverage of a single execution.
pub fn collect_coverage<D: typst::Document, F: CompilerFeat>(
base: &CompilerWorld<F>,
) -> Result<CoverageResult> {
let instr = InstrumentWorld {
base,
library: instrument_library(&base.library),
instr: CoverageInstrumenter::default(),
instrumented: Mutex::new(FxHashMap::default()),
};
let _cov_lock = cov::COVERAGE_LOCK.lock();
if let Err(e) = typst::compile::<D>(&instr).output {
print_diagnostics(&instr, e.iter(), tinymist_world::DiagnosticFormat::Human)
.context_ut("failed to print diagnostics")?;
bail!("");
}
let meta = std::mem::take(instr.instr.map.lock().deref_mut());
let CoverageMap { regions, .. } = std::mem::take(cov::COVERAGE_MAP.lock().deref_mut());
Ok(CoverageResult { meta, regions })
}
#[comemo::memoize]
fn instrument_library(library: &Arc<LazyHash<Library>>) -> Arc<LazyHash<Library>> {
let mut library = library.as_ref().clone();
library.global.scope_mut().define_func::<__cov_pc>();
Arc::new(library)
}
#[derive(PartialEq, Eq, PartialOrd, Ord)]
struct PackageSpecCmp<'a> {
/// The namespace the package lives in.
pub namespace: &'a EcoString,
/// The name of the package within its namespace.
pub name: &'a EcoString,
/// The package's version.
pub version: &'a PackageVersion,
}
impl<'a> From<&'a PackageSpec> for PackageSpecCmp<'a> {
fn from(spec: &'a PackageSpec) -> Self {
Self {
namespace: &spec.namespace,
name: &spec.name,
version: &spec.version,
}
}
}
/// The coverage result in the format of the VSCode coverage data.
pub type VscodeCoverage = HashMap<String, Vec<VscodeFileCoverageDetail>>;
/// Converts the coverage result to the VSCode coverage data.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct VscodeFileCoverageDetail {
/// Whether the location is being executed
pub executed: bool,
/// The location of the coverage.
pub location: LspRange,
}

View file

@ -1,10 +1,7 @@
//! Conversions between Typst and LSP types and representations
use std::cmp::Ordering;
use tinymist_std::path::PathClean;
use tinymist_world::vfs::PathResolution;
use typst::syntax::Source;
use crate::prelude::*;
@ -13,32 +10,7 @@ pub type LspPosition = lsp_types::Position;
/// An LSP range encoded by [`PositionEncoding`].
pub type LspRange = lsp_types::Range;
/// What counts as "1 character" for string indexing. We should always prefer
/// UTF-8, but support UTF-16 as long as it is standard. For more background on
/// encodings and LSP, try ["The bottom emoji breaks rust-analyzer"](https://fasterthanli.me/articles/the-bottom-emoji-breaks-rust-analyzer),
/// a well-written article on the topic.
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default)]
pub enum PositionEncoding {
/// "1 character" means "1 UTF-16 code unit"
///
/// This is the only required encoding for LSPs to support, but it's not a
/// natural one (unless you're working in JS). Prefer UTF-8, and refer
/// to the article linked in the `PositionEncoding` docs for more
/// background.
#[default]
Utf16,
/// "1 character" means "1 byte"
Utf8,
}
impl From<PositionEncoding> for lsp_types::PositionEncodingKind {
fn from(position_encoding: PositionEncoding) -> Self {
match position_encoding {
PositionEncoding::Utf16 => Self::UTF16,
PositionEncoding::Utf8 => Self::UTF8,
}
}
}
pub use tinymist_analysis::location::*;
const UNTITLED_ROOT: &str = "/untitled";
static EMPTY_URL: LazyLock<Url> = LazyLock::new(|| Url::parse("file://").unwrap());
@ -104,158 +76,8 @@ pub fn url_to_path(uri: Url) -> PathBuf {
uri.to_file_path().unwrap()
}
/// Convert an LSP position to a Typst position.
pub fn to_typst_position(
lsp_position: LspPosition,
lsp_position_encoding: PositionEncoding,
typst_source: &Source,
) -> Option<usize> {
let lines = typst_source.len_lines() as u32;
'bound_checking: {
let should_warning = match lsp_position.line.cmp(&lines) {
Ordering::Greater => true,
Ordering::Equal => lsp_position.character > 0,
Ordering::Less if lsp_position.line + 1 == lines => {
let last_line_offset = typst_source.line_to_byte(lines as usize - 1)?;
let last_line_chars = &typst_source.text()[last_line_offset..];
let len = match lsp_position_encoding {
PositionEncoding::Utf8 => last_line_chars.len(),
PositionEncoding::Utf16 => {
last_line_chars.chars().map(char::len_utf16).sum::<usize>()
}
};
match lsp_position.character.cmp(&(len as u32)) {
Ordering::Less => break 'bound_checking,
Ordering::Greater => true,
Ordering::Equal => false,
}
}
Ordering::Less => break 'bound_checking,
};
if should_warning {
log::warn!(
"LSP position is out of bounds: {:?}, while only {:?} lines and {:?} characters at the end.",
lsp_position, typst_source.len_lines(), typst_source.line_to_range(typst_source.len_lines() - 1),
);
}
return Some(typst_source.len_bytes());
}
match lsp_position_encoding {
PositionEncoding::Utf8 => {
let line_index = lsp_position.line as usize;
let column_index = lsp_position.character as usize;
typst_source.line_column_to_byte(line_index, column_index)
}
PositionEncoding::Utf16 => {
// We have a line number and a UTF-16 offset into that line. We want a byte
// offset into the file.
//
// Typst's `Source` provides several UTF-16 methods:
// - `len_utf16` for the length of the file
// - `byte_to_utf16` to convert a byte offset from the start of the file to a
// UTF-16 offset from the start of the file
// - `utf16_to_byte` to do the opposite of `byte_to_utf16`
//
// Unfortunately, none of these address our needs well, so we do some math
// instead. This is not the fastest possible implementation, but
// it's the most reasonable without access to the internal state
// of `Source`.
// TODO: Typst's `Source` could easily provide an implementation of the method
// we need here. Submit a PR against `typst` to add it, then
// update this if/when merged.
let line_index = lsp_position.line as usize;
let utf16_offset_in_line = lsp_position.character as usize;
let byte_line_offset = typst_source.line_to_byte(line_index)?;
let utf16_line_offset = typst_source.byte_to_utf16(byte_line_offset)?;
let utf16_offset = utf16_line_offset + utf16_offset_in_line;
typst_source.utf16_to_byte(utf16_offset)
}
}
}
/// Convert a Typst position to an LSP position.
pub fn to_lsp_position(
typst_offset: usize,
lsp_position_encoding: PositionEncoding,
typst_source: &Source,
) -> LspPosition {
if typst_offset > typst_source.len_bytes() {
return LspPosition::new(typst_source.len_lines() as u32, 0);
}
let line_index = typst_source.byte_to_line(typst_offset).unwrap();
let column_index = typst_source.byte_to_column(typst_offset).unwrap();
let lsp_line = line_index as u32;
let lsp_column = match lsp_position_encoding {
PositionEncoding::Utf8 => column_index as u32,
PositionEncoding::Utf16 => {
// See the implementation of `position_to_offset` for discussion
// relevant to this function.
// TODO: Typst's `Source` could easily provide an implementation of the method
// we need here. Submit a PR to `typst` to add it, then update
// this if/when merged.
let utf16_offset = typst_source.byte_to_utf16(typst_offset).unwrap();
let byte_line_offset = typst_source.line_to_byte(line_index).unwrap();
let utf16_line_offset = typst_source.byte_to_utf16(byte_line_offset).unwrap();
let utf16_column_offset = utf16_offset - utf16_line_offset;
utf16_column_offset as u32
}
};
LspPosition::new(lsp_line, lsp_column)
}
/// Convert an LSP range to a Typst range.
pub fn to_typst_range(
lsp_range: LspRange,
lsp_position_encoding: PositionEncoding,
source: &Source,
) -> Option<Range<usize>> {
let lsp_start = lsp_range.start;
let typst_start = to_typst_position(lsp_start, lsp_position_encoding, source)?;
let lsp_end = lsp_range.end;
let typst_end = to_typst_position(lsp_end, lsp_position_encoding, source)?;
Some(Range {
start: typst_start,
end: typst_end,
})
}
/// Convert a Typst range to an LSP range.
pub fn to_lsp_range(
typst_range: Range<usize>,
typst_source: &Source,
lsp_position_encoding: PositionEncoding,
) -> LspRange {
let typst_start = typst_range.start;
let lsp_start = to_lsp_position(typst_start, lsp_position_encoding, typst_source);
let typst_end = typst_range.end;
let lsp_end = to_lsp_position(typst_end, lsp_position_encoding, typst_source);
LspRange::new(lsp_start, lsp_end)
}
#[cfg(test)]
mod test {
use lsp_types::Position;
use super::*;
#[test]
@ -279,175 +101,4 @@ mod test {
let uri2 = path_to_url(&path).unwrap();
assert_eq!(EMPTY_URL.clone(), uri2);
}
const ENCODING_TEST_STRING: &str = "test 🥺 test";
#[test]
fn issue_14_invalid_range() {
let source = Source::detached("#set page(height: 2cm)");
let rng = LspRange {
start: LspPosition {
line: 0,
character: 22,
},
// EOF
end: LspPosition {
line: 1,
character: 0,
},
};
let res = to_typst_range(rng, PositionEncoding::Utf16, &source).unwrap();
assert_eq!(res, 22..22);
}
#[test]
fn issue_14_invalid_range_2() {
let source = Source::detached(
r"#let f(a) = {
a
}
",
);
let rng = LspRange {
start: LspPosition {
line: 2,
character: 1,
},
// EOF
end: LspPosition {
line: 3,
character: 0,
},
};
let res = to_typst_range(rng, PositionEncoding::Utf16, &source).unwrap();
assert_eq!(res, 19..source.len_bytes());
// EOF
let rng = LspRange {
start: LspPosition {
line: 3,
character: 1,
},
end: LspPosition {
line: 4,
character: 0,
},
};
let res = to_typst_range(rng, PositionEncoding::Utf16, &source).unwrap();
assert_eq!(res, source.len_bytes()..source.len_bytes());
for line in 0..=5 {
for character in 0..2 {
let off = to_typst_position(
Position { line, character },
PositionEncoding::Utf16,
&source,
);
assert!(off.is_some(), "line: {line}, character: {character}");
}
}
}
#[test]
fn overflow_offset_to_position() {
let source = Source::detached("test");
let offset = source.len_bytes();
let position = to_lsp_position(offset, PositionEncoding::Utf16, &source);
assert_eq!(
position,
LspPosition {
line: 0,
character: 4
}
);
let offset = source.len_bytes() + 1;
let position = to_lsp_position(offset, PositionEncoding::Utf16, &source);
assert_eq!(
position,
LspPosition {
line: 1,
character: 0
}
);
}
#[test]
fn utf16_position_to_utf8_offset() {
let source = Source::detached(ENCODING_TEST_STRING);
let start = LspPosition {
line: 0,
character: 0,
};
let emoji = LspPosition {
line: 0,
character: 5,
};
let post_emoji = LspPosition {
line: 0,
character: 7,
};
let end = LspPosition {
line: 0,
character: 12,
};
let start_offset = to_typst_position(start, PositionEncoding::Utf16, &source).unwrap();
let start_actual = 0;
let emoji_offset = to_typst_position(emoji, PositionEncoding::Utf16, &source).unwrap();
let emoji_actual = 5;
let post_emoji_offset =
to_typst_position(post_emoji, PositionEncoding::Utf16, &source).unwrap();
let post_emoji_actual = 9;
let end_offset = to_typst_position(end, PositionEncoding::Utf16, &source).unwrap();
let end_actual = 14;
assert_eq!(start_offset, start_actual);
assert_eq!(emoji_offset, emoji_actual);
assert_eq!(post_emoji_offset, post_emoji_actual);
assert_eq!(end_offset, end_actual);
}
#[test]
fn utf8_offset_to_utf16_position() {
let source = Source::detached(ENCODING_TEST_STRING);
let start = 0;
let emoji = 5;
let post_emoji = 9;
let end = 14;
let start_position = LspPosition {
line: 0,
character: 0,
};
let start_actual = to_lsp_position(start, PositionEncoding::Utf16, &source);
let emoji_position = LspPosition {
line: 0,
character: 5,
};
let emoji_actual = to_lsp_position(emoji, PositionEncoding::Utf16, &source);
let post_emoji_position = LspPosition {
line: 0,
character: 7,
};
let post_emoji_actual = to_lsp_position(post_emoji, PositionEncoding::Utf16, &source);
let end_position = LspPosition {
line: 0,
character: 12,
};
let end_actual = to_lsp_position(end, PositionEncoding::Utf16, &source);
assert_eq!(start_position, start_actual);
assert_eq!(emoji_position, emoji_actual);
assert_eq!(post_emoji_position, post_emoji_actual);
assert_eq!(end_position, end_actual);
}
}

View file

@ -188,6 +188,14 @@ impl Error {
pub fn arguments(&self) -> &[(&'static str, String)] {
self.err.args.as_deref().unwrap_or_default()
}
/// Returns the diagnostics attach to the error.
pub fn diagnostics(&self) -> Option<&[SourceDiagnostic]> {
match &self.err.kind {
ErrKind::RawDiag(diag) => Some(diag),
_ => None,
}
}
}
impl fmt::Display for Error {

View file

@ -28,7 +28,7 @@ pub use tinymist_vfs as vfs;
#[cfg(feature = "system")]
pub mod system;
#[cfg(feature = "system")]
pub use system::{SystemCompilerFeat, TypstSystemUniverse, TypstSystemWorld};
pub use system::{print_diagnostics, SystemCompilerFeat, TypstSystemUniverse, TypstSystemWorld};
/// Run the compiler in the browser environment.
#[cfg(feature = "browser")]
@ -146,6 +146,14 @@ pub trait CompilerFeat: Send + Sync + 'static {
type Registry: PackageRegistry + Send + Sync + Sized;
}
/// Which format to use for diagnostics.
#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Ord, PartialOrd)]
pub enum DiagnosticFormat {
#[default]
Human,
Short,
}
pub mod build_info {
/// The version of the reflexo-world crate.
pub static VERSION: &str = env!("CARGO_PKG_VERSION");

View file

@ -12,6 +12,9 @@ use crate::{
EntryState,
};
mod diag;
pub use diag::*;
/// type trait of [`TypstSystemWorld`].
#[derive(Debug, Clone, Copy)]
pub struct SystemCompilerFeat;

View file

@ -0,0 +1,82 @@
use std::io::IsTerminal;
use codespan_reporting::{
diagnostic::{Diagnostic, Label},
term::{
self,
termcolor::{ColorChoice, StandardStream},
},
};
use tinymist_std::Result;
use tinymist_vfs::FileId;
use typst::diag::{eco_format, Severity, SourceDiagnostic};
use typst::syntax::Span;
use typst::{World, WorldExt};
use crate::{DiagnosticFormat, SourceWorld};
/// Get stderr with color support if desirable.
fn color_stream() -> StandardStream {
StandardStream::stderr(if std::io::stderr().is_terminal() {
ColorChoice::Auto
} else {
ColorChoice::Never
})
}
/// Print diagnostic messages to the terminal.
pub fn print_diagnostics<'d, 'files, W: SourceWorld>(
world: &'files W,
errors: impl Iterator<Item = &'d SourceDiagnostic>,
diagnostic_format: DiagnosticFormat,
) -> Result<(), codespan_reporting::files::Error> {
let world = world.for_codespan_reporting();
let mut w = match diagnostic_format {
DiagnosticFormat::Human => color_stream(),
DiagnosticFormat::Short => StandardStream::stderr(ColorChoice::Never),
};
let mut config = term::Config {
tab_width: 2,
..Default::default()
};
if diagnostic_format == DiagnosticFormat::Short {
config.display_style = term::DisplayStyle::Short;
}
for diagnostic in errors {
let diag = match diagnostic.severity {
Severity::Error => Diagnostic::error(),
Severity::Warning => Diagnostic::warning(),
}
.with_message(diagnostic.message.clone())
.with_notes(
diagnostic
.hints
.iter()
.map(|e| (eco_format!("hint: {e}")).into())
.collect(),
)
.with_labels(label(world.world, diagnostic.span).into_iter().collect());
term::emit(&mut w, &config, &world, &diag)?;
// Stacktrace-like helper diagnostics.
for point in &diagnostic.trace {
let message = point.v.to_string();
let help = Diagnostic::help()
.with_message(message)
.with_labels(label(world.world, point.span).into_iter().collect());
term::emit(&mut w, &config, &world, &help)?;
}
}
Ok(())
}
/// Create a label for a span.
fn label<W: World>(world: &W, span: Span) -> Option<Label<FileId>> {
Some(Label::primary(span.id()?, world.range(span)?))
}

View file

@ -547,25 +547,6 @@ impl<F: CompilerFeat> CompilerWorld<F> {
))
}
/// Lookup a source file by id.
#[track_caller]
fn lookup(&self, id: FileId) -> Source {
self.source(id)
.expect("file id does not point to any source file")
}
fn map_source_or_default<T>(
&self,
id: FileId,
default_v: T,
f: impl FnOnce(Source) -> CodespanResult<T>,
) -> CodespanResult<T> {
match World::source(self, id).ok() {
Some(source) => f(source),
None => Ok(default_v),
}
}
pub fn revision(&self) -> NonZeroUsize {
self.revision
}
@ -750,6 +731,105 @@ impl<F: CompilerFeat> WorldDeps for CompilerWorld<F> {
}
}
pub trait SourceWorld: World {
fn path_for_id(&self, id: FileId) -> Result<PathResolution, FileError>;
fn lookup(&self, id: FileId) -> Source {
self.source(id)
.expect("file id does not point to any source file")
}
fn map_source_or_default<T>(
&self,
id: FileId,
default_v: T,
f: impl FnOnce(Source) -> CodespanResult<T>,
) -> CodespanResult<T> {
match self.source(id).ok() {
Some(source) => f(source),
None => Ok(default_v),
}
}
fn for_codespan_reporting(&self) -> CodeSpanReportWorld<Self>
where
Self: Sized,
{
CodeSpanReportWorld { world: self }
}
}
impl<F: CompilerFeat> SourceWorld for CompilerWorld<F> {
/// Resolve the real path for a file id.
fn path_for_id(&self, id: FileId) -> Result<PathResolution, FileError> {
self.path_for_id(id)
}
}
pub struct CodeSpanReportWorld<'a, T> {
pub world: &'a T,
}
impl<'a, T: SourceWorld> codespan_reporting::files::Files<'a> for CodeSpanReportWorld<'a, T> {
/// A unique identifier for files in the file provider. This will be used
/// for rendering `diagnostic::Label`s in the corresponding source files.
type FileId = FileId;
/// The user-facing name of a file, to be displayed in diagnostics.
type Name = String;
/// The source code of a file.
type Source = Source;
/// The user-facing name of a file.
fn name(&'a self, id: FileId) -> CodespanResult<Self::Name> {
Ok(match self.world.path_for_id(id) {
Ok(path) => path.as_path().display().to_string(),
Err(_) => format!("{id:?}"),
})
}
/// The source code of a file.
fn source(&'a self, id: FileId) -> CodespanResult<Self::Source> {
Ok(self.world.lookup(id))
}
/// See [`codespan_reporting::files::Files::line_index`].
fn line_index(&'a self, id: FileId, given: usize) -> CodespanResult<usize> {
let source = self.world.lookup(id);
source
.byte_to_line(given)
.ok_or_else(|| CodespanError::IndexTooLarge {
given,
max: source.len_bytes(),
})
}
/// See [`codespan_reporting::files::Files::column_number`].
fn column_number(&'a self, id: FileId, _: usize, given: usize) -> CodespanResult<usize> {
let source = self.world.lookup(id);
source.byte_to_column(given).ok_or_else(|| {
let max = source.len_bytes();
if given <= max {
CodespanError::InvalidCharBoundary { given }
} else {
CodespanError::IndexTooLarge { given, max }
}
})
}
/// See [`codespan_reporting::files::Files::line_range`].
fn line_range(&'a self, id: FileId, given: usize) -> CodespanResult<std::ops::Range<usize>> {
self.world.map_source_or_default(id, 0..0, |source| {
source
.line_to_range(given)
.ok_or_else(|| CodespanError::LineTooLarge {
given,
max: source.len_lines(),
})
})
}
}
// todo: remove me
impl<'a, F: CompilerFeat> codespan_reporting::files::Files<'a> for CompilerWorld<F> {
/// A unique identifier for files in the file provider. This will be used
/// for rendering `diagnostic::Label`s in the corresponding source files.
@ -763,51 +843,27 @@ impl<'a, F: CompilerFeat> codespan_reporting::files::Files<'a> for CompilerWorld
/// The user-facing name of a file.
fn name(&'a self, id: FileId) -> CodespanResult<Self::Name> {
Ok(match self.path_for_id(id) {
Ok(path) => path.as_path().display().to_string(),
Err(_) => format!("{id:?}"),
})
self.for_codespan_reporting().name(id)
}
/// The source code of a file.
fn source(&'a self, id: FileId) -> CodespanResult<Self::Source> {
Ok(self.lookup(id))
self.for_codespan_reporting().source(id)
}
/// See [`codespan_reporting::files::Files::line_index`].
fn line_index(&'a self, id: FileId, given: usize) -> CodespanResult<usize> {
let source = self.lookup(id);
source
.byte_to_line(given)
.ok_or_else(|| CodespanError::IndexTooLarge {
given,
max: source.len_bytes(),
})
self.for_codespan_reporting().line_index(id, given)
}
/// See [`codespan_reporting::files::Files::column_number`].
fn column_number(&'a self, id: FileId, _: usize, given: usize) -> CodespanResult<usize> {
let source = self.lookup(id);
source.byte_to_column(given).ok_or_else(|| {
let max = source.len_bytes();
if given <= max {
CodespanError::InvalidCharBoundary { given }
} else {
CodespanError::IndexTooLarge { given, max }
}
})
self.for_codespan_reporting().column_number(id, 0, given)
}
/// See [`codespan_reporting::files::Files::line_range`].
fn line_range(&'a self, id: FileId, given: usize) -> CodespanResult<std::ops::Range<usize>> {
self.map_source_or_default(id, 0..0, |source| {
source
.line_to_range(given)
.ok_or_else(|| CodespanError::LineTooLarge {
given,
max: source.len_lines(),
})
})
self.for_codespan_reporting().line_range(id, given)
}
}

View file

@ -79,6 +79,7 @@ typst-shim.workspace = true
typst-preview = { workspace = true, optional = true }
typst-ansi-hl.workspace = true
tinymist-task.workspace = true
tinymist-debug.workspace = true
typstfmt.workspace = true
typstyle-core.workspace = true
unicode-script.workspace = true

View file

@ -31,6 +31,9 @@ pub enum Commands {
#[cfg(feature = "preview")]
Preview(tinymist::tool::preview::PreviewCliArgs),
/// Execute a document and collect coverage
#[clap(hide(true))] // still in development
Cov(CompileOnceArgs),
/// Runs compile command like `typst-cli compile`
Compile(CompileArgs),
/// Generates build script for compilation

View file

@ -18,7 +18,9 @@ use serde_json::Value as JsonValue;
use sync_lsp::internal_error;
use sync_lsp::transport::{with_stdio_transport, MirrorArgs};
use sync_lsp::{LspBuilder, LspClientRoot, LspResult};
use tinymist::tool::project::{compile_main, generate_script_main, project_main, task_main};
use tinymist::tool::project::{
compile_main, coverage_main, generate_script_main, project_main, task_main,
};
use tinymist::world::TaskInputs;
use tinymist::{CompileConfig, Config, RegularInit, ServerState, SuperInit, UserActionTask};
use tinymist_core::LONG_VERSION;
@ -90,6 +92,7 @@ fn main() -> Result<()> {
match args.command.unwrap_or_default() {
Commands::Completion(args) => completion(args),
Commands::Cov(args) => coverage_main(args),
Commands::Compile(args) => RUNTIMES.tokio_runtime.block_on(compile_main(args)),
Commands::GenerateScript(args) => generate_script_main(args),
Commands::Query(query_cmds) => query_main(query_cmds),

View file

@ -7,6 +7,7 @@ use std::{
use clap_complete::Shell;
use reflexo::{path::unix_slash, ImmutPath};
use reflexo_typst::{diag::print_diagnostics, DiagnosticFormat};
use tinymist_std::{bail, error::prelude::*};
use crate::{project::*, task::ExportTask};
@ -105,6 +106,41 @@ impl LockFileExt for LockFile {
}
}
/// Runs project compilation(s)
pub fn coverage_main(args: CompileOnceArgs) -> Result<()> {
// Prepares for the compilation
let universe = args.resolve()?;
let world = universe.snapshot();
let result = Ok(()).and_then(|_| -> Result<()> {
let res =
tinymist_debug::collect_coverage::<tinymist_std::typst::TypstPagedDocument, _>(&world)?;
let cov_path = Path::new("target/coverage.json");
let res = serde_json::to_string(&res.to_json(&world)).context("coverage")?;
std::fs::create_dir_all(cov_path.parent().context("parent")?).context("create coverage")?;
std::fs::write(cov_path, res).context("write coverage")?;
Ok(())
});
print_diag_or_error(&world, result)
}
fn print_diag_or_error(world: &LspWorld, result: Result<()>) -> Result<()> {
if let Err(e) = result {
if let Some(diagnostics) = e.diagnostics() {
print_diagnostics(world, diagnostics.iter(), DiagnosticFormat::Human)
.context_ut("print diagnostics")?;
bail!("");
}
return Err(e);
}
Ok(())
}
/// Runs project compilation(s)
pub async fn compile_main(args: CompileArgs) -> Result<()> {
// Identifies the input and output

View file

@ -1062,6 +1062,11 @@
"title": "%extension.tinymist.command.tinymist.profileCurrentFile%",
"category": "Typst"
},
{
"command": "tinymist.profileCurrentFileCoverage",
"title": "%extension.tinymist.command.tinymist.profileCurrentFileCoverage%",
"category": "Typst"
},
{
"command": "tinymist.syncLabel",
"title": "%extension.tinymist.command.tinymist.syncLabel%",

View file

@ -0,0 +1,111 @@
import * as vscode from "vscode";
/**
* The active editor owning *typst language document* to track.
*/
let activeEditor: vscode.TextEditor | undefined;
export class IContext {
subscriptions: vscode.Disposable[];
fileLevelCodelens: ICommand[];
constructor(public context: vscode.ExtensionContext) {
this.subscriptions = context.subscriptions;
this.fileLevelCodelens = [];
// Tracks the active editor owning *typst language document*.
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor((editor: vscode.TextEditor | undefined) => {
const langId = editor?.document.languageId;
if (langId === "typst") {
activeEditor = editor;
} else if (editor === undefined || activeEditor?.document.isClosed) {
activeEditor = undefined;
}
}),
);
}
// todo: remove me
static currentActiveEditor(): vscode.TextEditor | undefined {
return activeEditor;
}
registerFileLevelCommand(command: IFileLevelCommand) {
this.fileLevelCodelens.push(command);
this.subscriptions.push(
vscode.commands.registerCommand(command.command, (...args: unknown[]) =>
command.execute(this.provideEditor({}), ...args),
),
);
}
provideEditor(context: FileLevelContext): FileLevelContext {
context.currentEditor = activeEditor || vscode.window.activeTextEditor;
return context;
}
getCwd(context: any) {
if (context.cwd) {
return context.cwd;
}
return this.getRootForUri(context.currentEditor?.document?.uri as vscode.Uri);
}
getRootForUri(uri?: vscode.Uri) {
const enclosedRoot = uri && vscode.workspace.getWorkspaceFolder(uri);
if (enclosedRoot) {
return enclosedRoot.uri.fsPath;
}
if (uri) {
return vscode.Uri.joinPath(uri, "..").fsPath;
}
return undefined;
}
isValidEditor(currentEditor: vscode.TextEditor | undefined): currentEditor is vscode.TextEditor {
if (!currentEditor) {
vscode.window.showWarningMessage("No editor found for command.");
return false;
}
return true;
}
// todo: provide it correctly.
tinymistExec?: ICommand<ExecContext, Promise<ExecResult | undefined>>;
showErrorMessage(message: string) {
vscode.window.showErrorMessage(message);
}
}
export interface FileLevelContext {
currentEditor?: vscode.TextEditor;
}
export interface ICommand<T = unknown, R = any> {
command: string;
execute(context: T, ...args: unknown[]): R;
}
export type IFileLevelCommand = ICommand<FileLevelContext>;
export interface ExecContext extends FileLevelContext {
isTTY?: boolean;
stdout?: (data: Buffer) => void;
stderr?: (data: Buffer) => void;
killer?: vscode.EventEmitter<void>;
}
export interface ExecResult {
stdout: Buffer;
stderr: Buffer;
code: number;
signal?: any;
}

View file

@ -7,6 +7,8 @@ import { extensionState } from "./state";
import { previewPreload } from "./features/preview";
import { onEnterHandler } from "./lsp.on-enter";
import { ExecContext, ExecResult, ICommand, IContext } from "./context";
import { spawn } from "cross-spawn";
/**
* The condition
@ -15,7 +17,7 @@ type FeatureCondition = boolean;
/**
* The initialization vector
*/
type ActivationVector = (context: ExtensionContext) => void;
type ActivationVector = (context: IContext) => void;
/**
* The initialization vector
*/
@ -88,6 +90,7 @@ export async function tinymistActivate(
): Promise<void> {
const { activateTable, config } = trait;
tinymist.context = context;
const contextExt = new IContext(context);
// Sets a global context key to indicate that the extension is activated
vscode.commands.executeCommand("setContext", "ext.tinymistActivated", true);
@ -101,7 +104,15 @@ export async function tinymistActivate(
// Initializes language client
if (extensionState.features.lsp) {
tinymist.initClient(config);
const executable = tinymist.probeEnvPath("tinymist.serverPath", config.serverPath);
config.probedServerPath = executable;
// todo: guide installation?
if (config.probedServerPath) {
tinymist.initClient(config);
}
contextExt.tinymistExec = makeExecCommand(contextExt, executable);
}
// Register Shared commands
context.subscriptions.push(
@ -115,7 +126,7 @@ export async function tinymistActivate(
// Activates platform-dependent features
for (const [condition, activate] of activateTable()) {
if (condition) {
activate(context);
activate(contextExt);
}
}
// Starts language client
@ -146,3 +157,62 @@ export async function tinymistDeactivate(
await tinymist.stop();
tinymist.context = undefined!;
}
function makeExecCommand(
context: IContext,
executable?: string,
): ICommand<ExecContext, Promise<ExecResult | undefined>> {
return {
command: "tinymist.executeCli",
execute: async (ctx, cliArgs: string[]) => {
if (!executable) {
return;
}
const cwd = context.getCwd(ctx);
const proc = spawn(executable, cliArgs, {
env: {
...process.env,
RUST_BACKTRACE: "1",
},
cwd,
});
if (ctx.killer) {
ctx.killer.event(() => {
proc.kill();
});
}
const capturedStdout: Buffer[] = [];
const capturedStderr: Buffer[] = [];
proc.stdout.on("data", (data: Buffer) => {
if (ctx.stdout) {
ctx.stdout(data);
} else {
capturedStdout.push(data);
}
});
proc.stderr.on("data", (data: Buffer) => {
if (ctx.stderr) {
ctx.stderr(data);
} else {
capturedStderr.push(data);
}
});
return new Promise<ExecResult>((resolve, reject) => {
proc.on("error", reject);
proc.on("exit", (code: any, signal) => {
resolve({
stdout: Buffer.concat(capturedStdout),
stderr: Buffer.concat(capturedStderr),
code: code || 0,
signal,
});
});
});
},
};
}

View file

@ -27,8 +27,10 @@ import { labelFeatureActivate } from "./features/label";
import { packageFeatureActivate } from "./features/package";
import { toolFeatureActivate } from "./features/tool";
import { copyAndPasteActivate, dragAndDropActivate } from "./features/drop-paste";
import { testingFeatureActivate } from "./features/testing";
import { FeatureEntry, tinymistActivate, tinymistDeactivate } from "./extension.shared";
import { LanguageClient } from "vscode-languageclient/node";
import { IContext } from "./context";
LanguageState.Client = LanguageClient;
@ -39,6 +41,7 @@ const systemActivateTable = (): FeatureEntry[] => [
[extensionState.features.dragAndDrop, dragAndDropActivate],
[extensionState.features.copyAndPaste, copyAndPasteActivate],
[extensionState.features.task, taskActivate],
[extensionState.features.testing, testingFeatureActivate],
[extensionState.features.devKit, devKitFeatureActivate],
[extensionState.features.preview, previewActivateInTinymist, previewDeactivate],
[extensionState.features.language, languageActivate],
@ -62,7 +65,7 @@ export async function deactivate(): Promise<void> {
});
}
function previewActivateInTinymist(context: ExtensionContext) {
function previewActivateInTinymist(context: IContext) {
const typstPreviewExtension = vscode.extensions.getExtension("mgt19937.typst-preview");
if (typstPreviewExtension) {
void vscode.window.showWarningMessage(
@ -75,10 +78,10 @@ function previewActivateInTinymist(context: ExtensionContext) {
// Runs Integrated preview extension
previewSetIsTinymist();
previewActivate(context, false);
previewActivate(context.context, false);
}
async function languageActivate(context: ExtensionContext) {
async function languageActivate(context: IContext) {
const client = tinymist.client;
if (!client) {
console.warn("activating language feature without starting the tinymist language server");
@ -190,7 +193,7 @@ async function languageActivate(context: ExtensionContext) {
const initTemplateCommand =
(inPlace: boolean) =>
(...args: string[]) =>
initTemplate(context, inPlace, ...args);
initTemplate(context.context, inPlace, ...args);
// prettier-ignore
context.subscriptions.push(
@ -220,7 +223,7 @@ async function languageActivate(context: ExtensionContext) {
commands.registerCommand("tinymist.triggerSuggestAndParameterHints", triggerSuggestAndParameterHints),
);
// context.subscriptions.push
const provider = new SymbolViewProvider(context);
const provider = new SymbolViewProvider(context.context);
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(SymbolViewProvider.Name, provider),
);

View file

@ -18,6 +18,7 @@ export async function activate(context: ExtensionContext): Promise<void> {
dragAndDrop: false,
copyAndPaste: false,
onEnter: false,
testing: false,
preview: false,
language: false,
renderDocs: false,

View file

@ -1,6 +1,7 @@
import * as vscode from "vscode";
import { IContext } from "../context";
export function devKitFeatureActivate(context: vscode.ExtensionContext) {
export function devKitFeatureActivate(context: IContext) {
vscode.commands.executeCommand("setContext", "ext.tinymistDevKit", true);
context.subscriptions.push({
dispose: () => {

View file

@ -15,14 +15,15 @@ import {
typstPasteUriEditKind,
Schemes,
} from "./drop-paste.def";
import { IContext } from "../context";
export function dragAndDropActivate(context: vscode.ExtensionContext) {
export function dragAndDropActivate(context: IContext) {
context.subscriptions.push(
vscode.languages.registerDocumentDropEditProvider(typstDocumentSelector, new DropProvider()),
);
}
export function copyAndPasteActivate(context: vscode.ExtensionContext) {
export function copyAndPasteActivate(context: IContext) {
const providedEditKinds = [
typstPasteLinkEditKind,
typstPasteUriEditKind,

View file

@ -1,7 +1,8 @@
import * as vscode from "vscode";
import { tinymist } from "../lsp";
import { IContext } from "../context";
export function labelFeatureActivate(context: vscode.ExtensionContext) {
export function labelFeatureActivate(context: IContext) {
const labelViewProvider = new LabelViewProvider();
context.subscriptions.push(
vscode.window.registerTreeDataProvider("tinymist.label-view", labelViewProvider),

View file

@ -1,8 +1,9 @@
import * as vscode from "vscode";
import { PackageInfo, SymbolInfo, tinymist } from "../lsp";
import { editorTool } from "./tool";
import { IContext } from "../context";
export function packageFeatureActivate(context: vscode.ExtensionContext) {
export function packageFeatureActivate(context: IContext) {
const packageView = new PackageViewProvider();
context.subscriptions.push(
vscode.window.registerTreeDataProvider("tinymist.package-view", packageView),
@ -17,7 +18,7 @@ export function packageFeatureActivate(context: vscode.ExtensionContext) {
const content = await vscode.commands.executeCommand<string>("markdown.api.render", docs);
await editorTool(context, "docs", { pkg, content });
await editorTool(context.context, "docs", { pkg, content });
} catch (e) {
console.error("show package docs error", e);
vscode.window.showErrorMessage(`Failed to show package documentation: ${e}`);

View file

@ -25,15 +25,12 @@ import {
tinymist,
} from "../lsp";
import { l10nMsg } from "../l10n";
import { IContext } from "../context";
/**
* The launch preview implementation which depends on `isCompat` of previewActivate.
*/
let launchImpl: typeof launchPreviewLsp;
/**
* The active editor owning *typst language document* to track.
*/
let activeEditor: vscode.TextEditor | undefined;
/**
* Preload the preview resources to reduce the latency of the first preview.
@ -88,18 +85,6 @@ export function previewActivate(context: vscode.ExtensionContext, isCompat: bool
),
);
// Tracks the active editor owning *typst language document*.
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor((editor: vscode.TextEditor | undefined) => {
const langId = editor?.document.languageId;
if (langId === "typst") {
activeEditor = editor;
} else if (editor === undefined || activeEditor?.document.isClosed) {
activeEditor = undefined;
}
}),
);
const launchBrowsingPreview = launch("webview", "doc", { isBrowsing: true });
const launchDevPreview = launch("webview", "doc", { isDev: true });
// Registers preview commands, check `package.json` for descriptions.
@ -169,7 +154,7 @@ export function previewActivate(context: vscode.ExtensionContext, isCompat: bool
*/
function launch(kind: "browser" | "webview", mode: "doc" | "slide", opts?: LaunchOpts) {
return async () => {
activeEditor = activeEditor || vscode.window.activeTextEditor;
const activeEditor = IContext.currentActiveEditor() || vscode.window.activeTextEditor;
if (!activeEditor) {
vscode.window.showWarningMessage("No active editor");
return;

View file

@ -1,9 +1,10 @@
import * as vscode from "vscode";
import { runExport } from "./tasks.export";
import { IContext } from "../context";
export const TYPST_TASK_SOURCE = "typst";
export function taskActivate(context: vscode.ExtensionContext) {
export function taskActivate(context: IContext) {
const provide = (cls: typeof TypstTaskProvider) =>
context.subscriptions.push(vscode.tasks.registerTaskProvider(cls.TYPE, new cls(context)));
@ -20,7 +21,7 @@ class TypstTaskProvider implements vscode.TaskProvider {
},
} as const;
constructor(private readonly context: vscode.ExtensionContext) {}
constructor(private readonly context: IContext) {}
static has(task: vscode.Task): boolean {
return task.definition.type === TypstTaskProvider.TYPE;

View file

@ -0,0 +1,168 @@
import * as vscode from "vscode";
import { IContext } from "../context";
import { VirtualConsole } from "../util";
import * as fs from "fs";
export function testingFeatureActivate(context: IContext) {
const testController = vscode.tests.createTestController(
"tinymist-tests",
"Typst Tests (Tinymist)",
);
const profileCoverage = testController.createRunProfile(
"tinymist-profile-coverage",
vscode.TestRunProfileKind.Coverage,
runCoverageTests,
);
context.subscriptions.push(testController, profileCoverage);
context.registerFileLevelCommand({
command: "tinymist.profileCurrentFileCoverage",
execute: async (ctx) => {
if (!context.isValidEditor(ctx.currentEditor)) {
return;
}
const document = ctx.currentEditor.document;
const includes = [
testController.createTestItem("tinymist-profile", "tinymist-profile", document.uri),
];
const testRunRequest = new vscode.TestRunRequest(
includes,
undefined,
profileCoverage,
false,
true,
);
const cc = new vscode.CancellationTokenSource();
runCoverageTests(testRunRequest, cc.token);
},
});
async function runCoverageTests(request: vscode.TestRunRequest, token: vscode.CancellationToken) {
const testRun = testController.createTestRun(request);
if (request.include?.length !== 1) {
context.showErrorMessage("Invalid tinymist test run request");
return;
}
const item = request.include[0];
const uri = item.uri;
if (!uri) {
context.showErrorMessage("Invalid tinymist test item uri");
return;
}
testRun.started(item);
const failed = (msg: string) => {
testRun.failed(item, new vscode.TestMessage(msg));
testRun.end();
};
const kind = request.profile?.kind;
const executable = context.tinymistExec;
if (!executable) {
failed("tinymist executable not found");
testRun.end();
return;
}
const vc = new VirtualConsole();
const killer = new vscode.EventEmitter<void>();
const terminal = vscode.window.createTerminal({
name: "document Coverage",
pty: {
onDidWrite: vc.writeEmitter.event,
open: () => {},
close: () => {
killer.fire();
},
},
});
terminal.show(true);
const coverageTask = executable.execute(
{
killer,
isTTY: true,
stdout: (data: Buffer) => {
vc.write(data.toString("utf8"));
},
stderr: (data: Buffer) => {
vc.write(data.toString("utf8"));
},
},
["cov", uri.fsPath],
);
const detailsFut = coverageTask.then<Map<string, vscode.FileCoverageDetail[]>>((res) => {
const details = new Map<string, vscode.FileCoverageDetail[]>();
if (!res || res.code !== 0) {
return details;
}
const cov_path = "target/coverage.json";
if (!fs.existsSync(cov_path)) {
return details;
}
const cov = fs.readFileSync(cov_path, "utf8");
const cov_json: Record<string, vscode.StatementCoverage[]> = JSON.parse(cov);
for (const [k, v] of Object.entries(cov_json)) {
details.set(
vscode.Uri.file(k).fsPath,
v.map((x) => new vscode.StatementCoverage(x.executed, x.location, x.branches)),
);
}
return details;
});
const p = request.profile;
const requiredCoverage = p && kind === vscode.TestRunProfileKind.Coverage;
if (requiredCoverage) {
p.loadDetailedCoverage = async (_testRun, fi, token) => {
await coverageTask;
if (token.isCancellationRequested) {
return [];
}
const details = await detailsFut;
return details.get(fi.uri.fsPath) || [];
};
}
const res = await coverageTask;
if (!res) {
return;
}
const { code } = res;
vc.write(`\nCoverage profiling exited with code ${code}...`);
if (code !== 0) {
failed(`Coverage profiling exited with code ${code}`);
return;
}
if (token.isCancellationRequested) {
vc.write("\nCoverage profiling cancelled...");
return;
}
if (requiredCoverage) {
const details = await detailsFut;
for (const [k, v] of details) {
const uri = vscode.Uri.file(k);
testRun.addCoverage(vscode.FileCoverage.fromDetails(uri, v));
}
}
testRun.passed(item);
testRun.end();
}
}

View file

@ -3,6 +3,7 @@ import { writeFile } from "fs/promises";
import { tinymist } from "../lsp";
import { extensionState, ExtensionContext } from "../state";
import { activeTypstEditor, base64Encode, loadHTMLFile } from "../util";
import { IContext } from "../context";
const USER_PACKAGE_VERSION = "0.0.1";
const FONTS_EXPORT_CONFIGURE_VERSION = "0.0.1";
@ -47,14 +48,14 @@ const toolDesc: Partial<Record<EditorToolName, ToolDescriptor>> = {
},
};
export function toolFeatureActivate(context: vscode.ExtensionContext) {
export function toolFeatureActivate(context: IContext) {
const toolView = new ToolViewProvider();
context.subscriptions.push(
vscode.window.registerTreeDataProvider("tinymist.tool-view", toolView),
...Object.values(toolDesc).map((desc) =>
vscode.commands.registerCommand(desc.command, async () => {
await editorTool(context, desc.toolId);
await editorTool(context.context, desc.toolId);
}),
),
);

View file

@ -176,7 +176,7 @@ export class LanguageState {
const RUST_BACKTRACE = isProdMode ? "1" : "full";
const run = {
command: tinymist.probeEnvPath("tinymist.serverPath", config.serverPath),
command: config.probedServerPath,
args: ["lsp", ...mirrorFlag],
options: { env: Object.assign({}, process.env, { RUST_BACKTRACE }) },
};

View file

@ -17,6 +17,7 @@ interface ExtensionState {
onEnter: boolean;
preview: boolean;
language: boolean;
testing: boolean;
renderDocs: boolean;
};
mut: {
@ -42,6 +43,7 @@ export const extensionState: ExtensionState = {
onEnter: false,
preview: false,
language: true,
testing: true,
renderDocs: false,
},
mut: {

View file

@ -648,43 +648,15 @@ zh = "显示字体视图"
zh-TW = "顯示字型視圖"
[extension.tinymist.command.tinymist.profileCurrentFile]
en = "Profile and visualize execution of the current Typst file"
ar = "تحليل وتصور تنفيذ ملف Typst الحالي"
bg = "Профилиране и визуализация на изпълнението на текущия Typst файл"
ca = "Perfilar i visualitzar l'execució del fitxer Typst actual"
cs = "Profilovat a vizualizovat provedení aktuálního souboru Typst"
da = "Profilér og visualiser udførelsen af den aktuelle Typst-fil"
de = "Profilieren und Visualisieren der Ausführung der aktuellen Typst-Datei"
el = "Προφίλ και απεικόνιση της εκτέλεσης του τρέχοντος αρχείου Typst"
es = "Perfilar y visualizar la ejecución del archivo Typst actual"
et = "Profiili ja visualiseeri praeguse Typst faili täitmine"
eu = "Profilatu eta bistaratu uneko Typst fitxategiaren exekuzioa"
fi = "Profiloi ja visualisoi nykyisen Typst-tiedoston suoritus"
fr = "Profiler et visualiser l'exécution du fichier Typst actuel"
gl = "Perfilar e visualizar a execución do ficheiro Typst actual"
he = "פרופיל וחזותי את ביצוע קובץ ה-Typst הנוכחי"
hu = "Profilozás és vizualizáció az aktuális Typst fájl végrehajtásához"
is = "Prófíl og sjónræna framkvæmd núverandi Typst skrár"
it = "Profilare e visualizzare l'esecuzione del file Typst corrente"
ja = "現在のTypstファイルの実行をプロファイリングおよび視覚化"
la = "Profile et visualiza exsecutionem fasciculi Typst currentis"
nb = "Profilér og visualiser utførelsen av den aktuelle Typst-filen"
nl = "Profileer en visualiseer de uitvoering van het huidige Typst-bestand"
nn = "Profiler og visualiser utføringa av den aktuelle Typst-fila"
pl = "Profiluj i wizualizuj wykonanie bieżącego pliku Typst"
pt-PT = "Perfilar e visualizar a execução do ficheiro Typst atual"
ro = "Profilare și vizualizare a execuției fișierului Typst curent"
ru = "Профилирование и визуализация выполнения текущего файла Typst"
sl = "Profiliraj in vizualiziraj izvedbo trenutne datoteke Typst"
sq = "Profili dhe vizualizo ekzekutimin e skedarit aktual Typst"
sr = "Профилиши и визуализуј извршење текућег Typst фајла"
sv = "Profilera och visualisera körningen av den aktuella Typst-filen"
tr = "Geçerli Typst dosyasının yürütülmesini profille ve görselleştir"
uk = "Профілювання та візуалізація виконання поточного файлу Typst"
vi = "Phân tích và hiển thị trực quan việc thực thi tệp Typst hiện tại"
zh = "对当前 Typst 文件的执行进行分析和可视化"
en = "Profile and visualize execution of the current Typst document"
zh = "对当前 Typst 文档的执行进行分析和可视化"
zh-TW = "對目前 Typst 檔案的執行進行分析和視覺化"
[extension.tinymist.command.tinymist.profileCurrentFileCoverage]
en = "Profile and visualize code coverage of the current Typst document"
zh = "对当前 Typst 文档的代码覆盖率进行分析和可视化"
zh-TW = "對目前 Typst 檔案的程式碼覆蓋率進行分析和視覺化"
[extension.tinymist.command.tinymist.syncLabel]
en = "Scan workspace and collect all labels again"
ar = "فحص مساحة العمل وجمع جميع التسميات مرة أخرى"