diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index ec390e25d..701374400 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -21,7 +21,7 @@ pub use syntax::{ CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, - WebReferenceKind, merge_visibility, set_external_syntax, + WebReferenceKind, merge_visibility, set_external_syntax, shift_pos, shift_range, }; /// Parsed form of `languages/.toml`. Backend-only; the UI gets [`LanguageInfo`]. @@ -332,3 +332,15 @@ pub struct SymbolTag { pub container: Option, pub range: Range, } + +/// One inline TS/JS `"); + cache.open(id, &lang, 0); + let first = cache.outline(id, &lang, 0, &doc.text()); + let hits = cache.stats().hits; + assert_eq!(cache.outline(id, &lang, 0, &doc.text()), first); + assert_eq!(cache.stats().hits, hits + 1, "{language}: memo hit"); + + let edits = edit(&mut doc, 0, 0, "\n"); + cache.edit(id, doc.version(), &edits); + let second = cache.outline(id, &lang, doc.version(), &doc.text()); + assert_ne!(second, first); + assert_eq!(second, cache.syntax().outline(&lang, &doc.text())); + // An older query must not replace the current version's memo. + cache.outline(id, &lang, 0, ""); + let hits = cache.stats().hits; + assert_eq!(cache.outline(id, &lang, doc.version(), &doc.text()), second); + assert_eq!(cache.stats().hits, hits + 1); + cache.clear(); + assert!(cache.entries.lock().get(&id).unwrap().lock().memo.outline.is_none()); + cache.close(id); + } + } + #[test] fn version_mismatch_and_lifecycle() { let cache = cache(); diff --git a/crates/ide-language/src/syntax/lines.rs b/crates/ide-language/src/syntax/lines.rs index 6fd0b1906..d34b5d5d0 100644 --- a/crates/ide-language/src/syntax/lines.rs +++ b/crates/ide-language/src/syntax/lines.rs @@ -96,6 +96,19 @@ pub(crate) fn byte_at(text: &str, pos: Pos) -> Option { Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize)) } +/// Shifts a position from a document suffix that starts at `base` into document +/// coordinates: the suffix's first line continues `base`'s line, later lines +/// add the base line. Used to map inline `\ + \ + \ + "; + let regions = syntax().script_regions(&lang("html"), src); + assert_eq!(regions.len(), 2, "module and plain scripts; json/importmap skipped"); + assert_eq!(regions[0].language.as_str(), "javascript"); +} + +#[test] +fn script_regions_pick_the_lang_attribute() { + let src = "\n"; + let regions = syntax().script_regions(&lang("vue"), src); + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].language.as_str(), "tsx"); + assert!(regions[0].text.contains("const App")); +} + +#[test] +fn script_regions_empty_for_other_languages() { + assert!(syntax().script_regions(&lang("rust"), "fn main() {}").is_empty()); + assert!(syntax().script_regions(&lang("vue"), "").is_empty()); +} + +#[test] +fn script_regions_classify_attributes() { + for language in ["vue", "html"] { + for (attributes, expected) in [ + ("", Some("javascript")), + ("setup lang=ts", Some("typescript")), + ("lang='typescript'", Some("typescript")), + ("lang=tsx", Some("tsx")), + ("lang=js", Some("javascript")), + ("lang=jsx", Some("javascript")), + ("LANG=TS", Some("typescript")), + ("type=module", Some("javascript")), + ("TYPE=' text/JAVASCRIPT '", Some("javascript")), + ("type=application/javascript", Some("javascript")), + ("type=application/json", None), + ("type='application/ld+json'", None), + ("type=text/template", None), + ("type=importmap", None), + ("type=speculationrules", None), + ("lang=coffee", None), + ("lang=ts type=application/json", None), + ("src='./external.ts' lang=ts", None), + ("SRC=external.js", None), + ("src", None), + ] { + let src = format!(""); + let regions = syntax().script_regions(&lang(language), &src); + assert_eq!(regions.len(), usize::from(expected.is_some()), "{language}: {attributes}"); + if let Some(expected) = expected { + assert_eq!(regions[0].language.as_str(), expected, "{attributes}"); + assert_eq!(regions[0].text, "const value = 1;"); + assert_eq!(&src[regions[0].base..regions[0].base + regions[0].text.len()], regions[0].text); + } + } + } +} + +#[test] +fn script_regions_preserve_document_offsets_and_line_endings() { + use super::lines::LineIndex; + + let src = "\r\n\r\n"; + let regions = syntax().script_regions(&lang("vue"), src); + assert_eq!(regions.len(), 2); + let idx = LineIndex::new(src); + let doc = ide_text::Document::new(src); + for (region, (body, pos)) in + regions.iter().zip([("const 名 = 'πŸ˜€';\r\n", Pos::new(1, 16)), ("const View = () =>
;", Pos::new(3, 23))]) + { + assert_eq!(region.base, src.find(body).unwrap()); + assert_eq!(region.text, body); + assert_eq!(idx.pos(region.base), pos); + assert_eq!(idx.pos(region.base), doc.byte_to_pos(src[..region.base].replace("\r\n", "\n").len())); + assert_eq!( + idx.pos(region.base + body.len()), + doc.byte_to_pos(src[..region.base + body.len()].replace("\r\n", "\n").len()) + ); + if let Some(name) = body.find('名') { + assert_eq!( + idx.range(region.base + name, region.base + name + '名'.len_utf8()), + Range::new(Pos::new(1, 22), Pos::new(1, 23)) + ); + } + } + let same_line = ""; + let region = &syntax().script_regions(&lang("html"), same_line)[0]; + assert_eq!(LineIndex::new(same_line).pos(region.base), Pos::new(0, 33)); +} + +#[test] +fn script_regions_tolerate_incomplete_markup() { + for src in [ + "ignored\r\n".repeat(count); + let start = std::time::Instant::now(); + let regions = syntax().script_regions(&lang("vue"), &src); + eprintln!("script regions: {} bytes, {count} blocks: {:?}", src.len(), start.elapsed()); + assert_eq!(regions.len(), count); + assert!(regions.windows(2).all(|pair| pair[0].base < pair[1].base)); + } +} + +#[test] +fn script_regions_tolerate_deep_markup() { + let src = "
".repeat(1_000) + ""; + let regions = syntax().script_regions(&lang("html"), &src); + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].base, src.find("const").unwrap()); +} + +// --------------------------------------------------------------------------- +// Outline with nested \n\ + \n"; + check_outline( + "html", + src, + " + Field html + Field body + Field script + Function boot + Constant app + Field script + ", + ); +} + +#[test] +fn vue_outline_nests_script_declarations_without_a_grammar() { + let src = "\n\ + \n\ + "; + check_outline( + "vue", + src, + " + Field template + Field script + Interface Props + Property size + Function scale + Field style + ", + ); +} + +#[test] +fn script_outline_positions_are_document_coordinates() { + use super::lines::LineIndex; + let src = "\r\n"; + let outline = syntax().outline(&lang("vue"), src); + let decl = &outline.iter().find(|i| i.name == "script").unwrap().children[0]; + // Cross-check against the document's own byte -> UTF-16 conversion. + let idx = LineIndex::new(src); + let at = src.find("answer").unwrap(); + assert_eq!(decl.selection_range, idx.range(at, at + "answer".len())); + // `@def.constant` captures the declarator (`answer = 42`), not the `const` keyword. + assert_eq!(decl.range, idx.range(at, src.find("42;").unwrap() + 2)); +} + +#[test] +fn cached_outline_matches_syntax_outline_for_web_documents() { + use ide_core::DocId; + use std::sync::Arc; + let cache = SyntaxCache::new(Arc::new(Syntax::new())); + let id = DocId::next(); + let src = "\n\n"; + cache.open(id, &lang("vue"), 0); + assert_eq!(cache.outline(id, &lang("vue"), 0, src), syntax().outline(&lang("vue"), src)); + cache.open(id, &lang("html"), 0); + assert_eq!(cache.outline(id, &lang("html"), 0, src), syntax().outline(&lang("html"), src)); +} + +#[test] +fn script_outlines_compose_positions_across_multiple_blocks() { + use super::lines::LineIndex; + + let src = "\r\n"; + let lines = LineIndex::new(src); + for id in ["html", "vue", "svelte", "jsp"] { + let items = syntax().outline(&lang(id), src); + let scripts: Vec<_> = items.iter().filter(|item| item.name == "script").collect(); + assert_eq!(scripts.len(), 2, "{id}"); + assert_eq!(scripts[0].children.len(), 1); + assert_eq!(scripts[1].children.len(), 2); + for (item, name) in scripts.iter().flat_map(|s| &s.children).zip(["first", "second", "third"]) { + let byte = src.find(name).unwrap(); + assert_eq!(item.name, name); + assert_eq!(item.selection_range, lines.range(byte, byte + name.len()), "{id}: {name}"); + } + } +} + +#[test] +fn script_outline_attaches_to_case_insensitive_html_tags() { + check_outline("html", "", "Field SCRIPT\n Function boot"); +} diff --git a/crates/ide-language/src/syntax/web.rs b/crates/ide-language/src/syntax/web.rs index 88a287570..6ef8d8232 100644 --- a/crates/ide-language/src/syntax/web.rs +++ b/crates/ide-language/src/syntax/web.rs @@ -81,13 +81,20 @@ fn css_regions<'t>(grammar: &str, tree: &Tree, text: &'t str) -> Vec<(usize, &'t } /// Pre-order walk; `f` returns whether to descend. -fn walk<'t>(n: Node<'t>, f: &mut dyn FnMut(Node<'t>) -> bool) { - if !f(n) { - return; - } - let mut c = n.walk(); - for child in n.children(&mut c) { - walk(child, f); +pub(crate) fn walk<'t>(n: Node<'t>, f: &mut dyn FnMut(Node<'t>) -> bool) { + let mut cursor = n.walk(); + loop { + if f(cursor.node()) && cursor.goto_first_child() { + continue; + } + loop { + if cursor.goto_next_sibling() { + break; + } + if !cursor.goto_parent() { + return; + } + } } } diff --git a/crates/ide-syntax/src/lib.rs b/crates/ide-syntax/src/lib.rs index 34dc123f2..2c1dacd21 100644 --- a/crates/ide-syntax/src/lib.rs +++ b/crates/ide-syntax/src/lib.rs @@ -33,7 +33,7 @@ pub use jvm::{JvmPresentation, JvmTypeKind, jvm_type_header}; pub use types::*; use ide_core::{CancellationToken, DocId, IdeError, LanguageId, Pos, Range}; -use ide_language::{ParsedTree, SyntaxCache, TreeError}; +use ide_language::{ParsedTree, Syntax, SyntaxCache, TreeError, shift_range}; use kinds::Kinds; use lines::Lines; use std::sync::Arc; @@ -143,8 +143,15 @@ impl StructureService { /// What the service can answer for `language`. pub fn support(&self, language: &LanguageId) -> StructureSupport { - match self.cache.syntax().grammar_name(language) { - Some(g) => StructureSupport { tree: true, locals: kinds::locals_source(g).is_some() }, + let syn = self.cache.syntax(); + // Web containers (Vue SFC / Svelte / JSP have no grammar of their own) + // answer from the HTML tree; inline \n"; + let vue = lang("vue"); + // `size` inside the script: a TypeScript identifier, at document coordinates. + let at = service().node_at(&snap(&vue, src), occ(src, "size", 1), &none()).unwrap().unwrap(); + assert_eq!(at.node.kind, "identifier"); + assert_eq!(at.node.range, occ(src, "size", 1), "document coordinates"); + assert_eq!(at.node.field.as_deref(), Some("name")); + // Template markup is not TypeScript: an empty answer, not markup nodes. + assert!(service().node_at(&snap(&vue, src), occ(src, "div", 1), &none()).unwrap().is_none()); +} + +#[test] +fn locals_inside_a_script_region_shift_every_range() { + let src = "\n\n"; + let vue = lang("vue"); + let byte = occurrences(src, "count")[0]; + let l = service().locals(&snap(&vue, src), pos_of(src, byte), &none()).unwrap(); + let Locals::Local { symbol } = l else { + panic!("expected a local, got {l:?}"); + }; + assert_eq!(symbol.name, "count"); + assert!(symbol.certain); + assert_eq!(symbol.definition, occ(src, "count", 1), "definition in document coordinates"); + assert_eq!(symbol.references, vec![occ(src, "count", 2)], "references in document coordinates"); + // Template: no TypeScript identifier here, so nothing local. + let byte = occurrences(src, "div")[0]; + let l = service().locals(&snap(&vue, src), pos_of(src, byte), &none()).unwrap(); + assert!(matches!(l, Locals::NoIdentifier)); +} + +#[test] +fn web_script_queries_honour_cancellation_outside_regions() { + let cancel = CancellationToken::new(); + cancel.cancel(); + for id in ["html", "vue", "svelte", "jsp"] { + let language = lang(id); + let src = ""; + let snap = snap(&language, src); + assert_eq!(service().node_at(&snap, Range::point(Pos::new(0, 2)), &cancel), Err(StructureError::Cancelled)); + assert_eq!(service().locals(&snap, Pos::new(0, 2), &cancel), Err(StructureError::Cancelled)); + } +} + +#[test] +fn web_script_queries_compose_utf16_positions_and_isolate_blocks() { + let src = "\r\n"; + for id in ["html", "vue", "svelte", "jsp"] { + let language = lang(id); + for (at, def, reference) in [(2, 1, 2), (4, 3, 4)] { + assert_local(id, src, "名", at, def, &[reference]); + let r = occ(src, "名", at); + let node = service().node_at(&snap(&language, src), r, &none()).unwrap().unwrap(); + assert_eq!(node.node.range, r); + assert_eq!(node.node.kind, "identifier"); + } + } +} + +#[test] +fn web_script_queries_follow_open_document_edits() { + use ide_core::{ChangeSet, TextChange}; + + for id in ["html", "vue", "svelte", "jsp"] { + let cache = Arc::new(SyntaxCache::new(Arc::new(Syntax::new()))); + let service = StructureService::new(cache.clone()); + let language = lang(id); + let id = DocId::next(); + let mut doc = ide_text::Document::new(""); + cache.open(id, &language, 0); + for version in 0..2 { + let text = doc.text(); + let snap = Snapshot::doc(id, doc.version(), &language, &text); + let range = occ(&text, "x", 2); + let at = service.node_at(&snap, range, &none()).unwrap().unwrap(); + assert_eq!(at.node.range, range); + let Locals::Local { symbol } = service.locals(&snap, range.start, &none()).unwrap() else { + panic!("expected local"); + }; + assert_eq!(symbol.definition, occ(&text, "x", 1)); + assert_eq!(symbol.references, [range]); + if version == 0 { + let changes = + ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "\n".into() }] }; + let (_, edits) = doc.apply_detailed(doc.version(), &changes).unwrap(); + cache.edit(id, doc.version(), &edits); + } + } + } +} + +#[test] +fn web_script_queries_preserve_parse_size_errors() { + let src = " ".repeat(ide_language::MAX_PARSE_BYTES + 1); + for id in ["html", "vue", "svelte", "jsp"] { + let language = lang(id); + let snap = snap(&language, &src); + assert_eq!(service().node_at(&snap, Range::point(Pos::new(0, 0)), &none()), Err(StructureError::TooLarge)); + assert_eq!(service().locals(&snap, Pos::new(0, 0), &none()), Err(StructureError::TooLarge)); + } +} diff --git a/src/editor/core/view/statusItems.test.tsx b/src/editor/core/view/statusItems.test.tsx index 79efab35c..e4df5d5fe 100644 --- a/src/editor/core/view/statusItems.test.tsx +++ b/src/editor/core/view/statusItems.test.tsx @@ -73,6 +73,17 @@ describe("caret-driven status widgets", () => { expect(screen.queryByText("place")).toBeNull(); expect(commits).toBeGreaterThan(0); }); + + it("follows the caret into a Vue component's