fix: correct jump_from_cursor and add tests (#1589)

* test: add tests to jump from cursor

* fix: fuzzy search

* fix: stupid filter

* docs: add comments

* docs: add some todos

* fix: comment wording

* dev: add a todo

* dev: edit a todo

* fix: comment
This commit is contained in:
Myriad-Dreamin 2025-03-27 01:44:49 +08:00 committed by GitHub
parent ee1c0ace46
commit 41f8881e8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 155 additions and 21 deletions

View file

@ -0,0 +1,4 @@
/// compile: true
/* range after 7..10 */
$ H e l l o W o r l d $

View file

@ -0,0 +1,4 @@
/// compile: true
/* range after 4..9 */
Hello World

View file

@ -0,0 +1,9 @@
---
source: crates/tinymist-query/src/jump.rs
description: "Jump cursor on */\n$ H e |l l| o W o r l)"
expression: results
input_file: crates/tinymist-query/src/fixtures/jump_from_cursor/markup_text.typ
---
nothing
1,283.354pt,78.500pt
nothing

View file

@ -0,0 +1,11 @@
---
source: crates/tinymist-query/src/jump.rs
description: "Jump cursor on ..9 */\nHel|lo Wo|rld\n)"
expression: results
input_file: crates/tinymist-query/src/fixtures/jump_from_cursor/math_text.typ
---
1,70.866pt,78.104pt
1,70.866pt,78.104pt
1,70.866pt,78.104pt
1,70.866pt,78.104pt
1,70.866pt,78.104pt

View file

@ -103,22 +103,60 @@ fn jump_from_cursor_(
source: &Source,
cursor: usize,
) -> Option<Vec<Position>> {
// todo: leaf_at_compat only matches the text before the cursor, but we could
// also match a text if it is after the cursor
// The case `leaf_at_compat` will match: `Hello|`
// FIXME: The case `leaf_at_compat` will not match: `|Hello`
let node = LinkedNode::new(source.root()).leaf_at_compat(cursor)?;
// todo: When we click on a label or some math operators, we seems likely also
// be able to jump to some place.
if !matches!(node.kind(), SyntaxKind::Text | SyntaxKind::MathText) {
return None;
};
let span = node.span();
let offset = cursor.saturating_sub(node.offset());
// todo: The cursor may not exact hit at the start of some AST node. For
// example, the cursor in the text element `Hell|o` is offset by 4 from the
// node. It seems not pretty if we ignore the offset completely.
let _ = offset;
match document {
TypstDocument::Paged(paged_doc) => {
let node = LinkedNode::new(source.root())
.leaf_at_compat(cursor)
.filter(|node| !matches!(node.kind(), SyntaxKind::Text | SyntaxKind::MathText))?;
let span = node.span();
// We checks whether there are any elements exactly matching the
// cursor position.
let mut positions = vec![];
// Unluckily, we might not be able to find the exact spans, so we
// need to find the closest one at the same time.
let mut min_page = 0;
let mut min_point = Point::default();
let mut min_dis = u64::MAX;
for (idx, page) in paged_doc.pages.iter().enumerate() {
let mut min_dis = u64::MAX;
let mut point = Point::default();
if let Some(point) = find_in_frame(&page.frame, span, &mut min_dis, &mut point) {
// In a page, we try to find a closer span than the existing found one.
let mut p_dis = min_dis;
if let Some(point) = find_in_frame(&page.frame, span, &mut p_dis, &mut min_point) {
if let Some(page) = NonZeroUsize::new(idx + 1) {
positions.push(Position { page, point });
}
}
// In this page, we found a closer span and update.
if p_dis != min_dis {
min_page = idx;
min_dis = p_dis;
}
}
// If we didn't find any exact span, we add the closest one in the same page.
if positions.is_empty() && min_dis != u64::MAX {
positions.push(Position {
page: NonZeroUsize::new(min_page + 1)?,
point: min_point,
});
}
Some(positions)
@ -142,13 +180,17 @@ fn find_in_frame(frame: &Frame, span: Span, min_dis: &mut u64, res: &mut Point)
if glyph.span.0 == span {
return Some(pos);
}
if glyph.span.0.id() == span.id() {
let dis = glyph
.span
.0
.into_raw()
.get()
.abs_diff(span.into_raw().get());
// We at least require that the span is in the same file.
let is_same_file = glyph.span.0.id() == span.id();
if is_same_file {
// The numbers are not offsets but a unique id on the AST tree which are
// nicely divided.
// FIXME: since typst v0.13.0, the numbers are not only the ids, but also raw
// ranges, See [`Span::range`].
let glyph_num = glyph.span.0.into_raw();
let span_num = span.into_raw().get();
let dis = glyph_num.get().abs_diff(span_num);
if dis < *min_dis {
*min_dis = dis;
*res = pos;
@ -167,3 +209,50 @@ fn find_in_frame(frame: &Frame, span: Span, min_dis: &mut u64, res: &mut Point)
fn is_in_rect(pos: Point, size: Size, click: Point) -> bool {
pos.x <= click.x && pos.x + size.x >= click.x && pos.y <= click.y && pos.y + size.y >= click.y
}
#[cfg(test)]
mod tests {
use itertools::Itertools;
use super::*;
use crate::tests::*;
#[test]
fn test() {
snapshot_testing("jump_from_cursor", &|ctx, path| {
let source = ctx.source_by_path(&path).unwrap();
let docs = find_module_level_docs(&source).unwrap_or_default();
let properties = get_test_properties(&docs);
let graph = compile_doc_for_test(ctx, &properties);
let document = graph.snap.success_doc.as_ref().unwrap();
let cursors = find_test_range_(&source);
let results = cursors
.map(|cursor| {
let points = jump_from_cursor(document, &source, cursor);
if points.is_empty() {
return "nothing".to_string();
}
points
.iter()
.map(|pos| {
let page = pos.page.get();
let point = pos.point;
format!("{page},{:.3}pt,{:.3}pt", point.x.to_pt(), point.y.to_pt())
})
.join(";")
})
.join("\n");
insta::with_settings!({
description => format!("Jump cursor on {})", make_range_annoation(&source)),
}, {
assert_snapshot!(results);
})
});
}
}

View file

@ -20,15 +20,15 @@ use tinymist_world::{EntryManager, EntryReader, EntryState, ShadowApi, TaskInput
use typst::foundations::Bytes;
use typst::syntax::ast::{self, AstNode};
use typst::syntax::{LinkedNode, Source, SyntaxKind, VirtualPath};
use typst_shim::syntax::LinkedNodeExt;
pub use crate::syntax::find_module_level_docs;
pub use insta::assert_snapshot;
pub use serde::Serialize;
pub use serde_json::json;
pub use tinymist_project::{LspUniverse, LspUniverseBuilder};
pub use tinymist_world::WorldComputeGraph;
use typst_shim::syntax::LinkedNodeExt;
use crate::syntax::find_module_level_docs;
use crate::{analysis::Analysis, prelude::LocalContext, LspPosition, PositionEncoding};
use crate::{to_lsp_position, CompletionFeat, LspWorldExt};
@ -234,14 +234,19 @@ pub fn find_test_range_(s: &Source) -> Range<usize> {
}
pub fn find_test_position_after(s: &Source) -> LspPosition {
find_test_position_(s, 1)
find_test_lsp_pos(s, 1)
}
pub fn find_test_position(s: &Source) -> LspPosition {
find_test_position_(s, 0)
find_test_lsp_pos(s, 0)
}
pub fn find_test_position_(s: &Source, offset: usize) -> LspPosition {
pub fn find_test_lsp_pos(s: &Source, offset: usize) -> LspPosition {
let node = find_test_typst_pos(s);
to_lsp_position(node + offset, PositionEncoding::Utf16, s)
}
pub fn find_test_typst_pos(s: &Source) -> usize {
enum AstMatcher {
MatchAny { prev: bool },
MatchIdent { prev: bool },
@ -330,7 +335,19 @@ pub fn find_test_position_(s: &Source, offset: usize) -> LspPosition {
break;
}
to_lsp_position(n.offset() + offset, PositionEncoding::Utf16, s)
n.offset()
}
pub fn make_range_annoation(source: &Source) -> String {
let range = find_test_range_(source);
let range_before = range.start.saturating_sub(10)..range.start;
let range_window = range.clone();
let range_after = range.end..range.end.saturating_add(10).min(source.text().len());
let window_before = &source.text()[range_before];
let window_line = &source.text()[range_window];
let window_after = &source.text()[range_after];
format!("{window_before}|{window_line}|{window_after}")
}
// pub static REDACT_URI: Lazy<RedactFields> = Lazy::new(||