feat(syntax): support structure and local navigation in inline scripts (#1593)

Without a language server, inline <script> blocks of HTML containers (vue, html, svelte, jsp) had no File Structure entries, breadcrumbs or Go to Declaration. The structure service now nests each script block's TS/JS declarations under its script entry, with region-local UTF-16 positions mapped to document coordinates, and the declaration fallback answers from the block grammar's tree inside a script. Language server answers still take precedence. Covers multiple and uppercase script blocks, CRLF, multi-byte text before a script, memo reuse per document version and parser errors.

Co-authored-by: qtvbwfn <[email protected]>
Цей коміт міститься в:
Qingтаqtvbwfn authored and GitHub committed 2026-11-10 00:43:33 +08:00
1 джерело 226ac26954
коміт 29af5994e3
14 files changed
+835 -19

No files matched your search

+13 -1
Переглянути файл
@@ -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/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
@@ -332,3 +332,15 @@ pub struct SymbolTag {
pub container: Option<String>,
pub range: Range,
}
/// One inline TS/JS `<script>` block of a Vue SFC / HTML document.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScriptRegion {
/// Document-absolute UTF-8 byte offset of `text`, for tree-sitter parsing.
/// Convert to UTF-16 positions before exposing ranges through IPC.
pub base: usize,
/// The language the block's `lang` attribute picks.
pub language: LanguageId,
/// The script text.
pub text: String,
}
+43 -3
Переглянути файл
@@ -195,8 +195,16 @@ impl SyntaxCache {
|| {
self.with_tree(doc, lang, version, text, |g, tree, text| {
let query = g.outline_query()?;
Some(outline::outline(g, query, tree, text))
// HTML documents answer from the cached tree, with nested
// <script> declarations.
Some(outline::outline_nested(g, query, tree, text, &|rl, rt| self.syntax.outline(rl, rt)))
})
// Web containers (Vue SFC / Svelte / JSP) have no grammar of their
// own, hence no cached tree: the syntax layer answers with a fresh
// HTML parse, which the memo covers per version.
// shortcut: no incremental re-parse for containers, upgrade when
// their trees join the cache.
.or_else(|| self.syntax.web_outline(lang, text))
.or_else(|| super::external::outline(lang, text))
},
)
@@ -256,8 +264,11 @@ impl SyntaxCache {
let Some(result) = compute() else { return Vec::new() };
if let Some(e) = &entry {
let mut e = e.lock();
// Not if the document moved on (or its tree was dropped) meanwhile.
if e.version == version && e.tree.is_some() {
// Containers without their own grammar memoize their web outline too.
// Never store an answer after the document moves on.
if e.version == version
&& (e.tree.is_some() || (e.lang == *lang && self.syntax.web_grammar_name(lang) == Some("html")))
{
*slot(e.memo.at(version, lang)) = Some(Arc::new(result.clone()));
}
}
@@ -474,6 +485,35 @@ mod tests {
assert!(cache.outline(id, &LanguageId::new("plaintext"), 1, &text).is_empty());
}
#[test]
fn web_outline_memo_tracks_versions_without_a_cached_tree() {
for language in ["html", "vue", "svelte", "jsp"] {
let cache = cache();
let id = DocId::next();
let lang = LanguageId::new(language);
let mut doc = Document::new("<script setup lang=ts>const first = 1;</script>");
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, "<script>const second = 2;</script>\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, "<script>const old = 0;</script>");
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();
+25
Переглянути файл
@@ -96,6 +96,19 @@ pub(crate) fn byte_at(text: &str, pos: Pos) -> Option<usize> {
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 `<script>` region answers (Vue SFC /
/// HTML) back onto the whole document; positions are 0-based lines / UTF-16 cols.
pub fn shift_pos(pos: Pos, base: Pos) -> Pos {
if pos.line == 0 { Pos::new(base.line, base.col + pos.col) } else { Pos::new(base.line + pos.line, pos.col) }
}
/// [`shift_pos`] for both ends of a range.
pub fn shift_range(range: Range, base: Pos) -> Range {
Range::new(shift_pos(range.start, base), shift_pos(range.end, base))
}
pub(crate) fn utf16_to_byte(s: &str, offset: usize) -> usize {
let mut units = 0;
for (i, c) in s.char_indices() {
@@ -160,6 +173,18 @@ mod tests {
}
}
#[test]
fn shifts_suffix_positions_into_document_coordinates() {
let base = Pos::new(3, 7);
assert_eq!(shift_pos(Pos::new(0, 2), base), Pos::new(3, 9));
assert_eq!(shift_pos(Pos::new(0, 0), base), base);
assert_eq!(shift_pos(Pos::new(2, 1), base), Pos::new(5, 1));
assert_eq!(
shift_range(Range::new(Pos::new(0, 1), Pos::new(1, 4)), base),
Range::new(Pos::new(3, 8), Pos::new(4, 4))
);
}
#[test]
fn long_minified_line_is_linear() {
// 200k symbols-worth of positions on one 4 MB line.
+43 -1
Переглянути файл
@@ -10,15 +10,17 @@ pub(crate) mod lines;
mod literals;
mod outline;
mod selection;
mod sfc;
mod web;
use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag};
use crate::{FoldRange, LanguageConfig, LanguageRegistry, ScriptRegion, SymbolTag};
pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats};
pub use configuration::ConfigurationKey;
pub use external::{ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken, set_external_syntax};
use grammars::{SPECS, Spec};
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
use ide_core::{LanguageId, OutlineItem, Pos, Range};
pub use lines::{shift_pos, shift_range};
pub use literals::{Literal, LiteralKind, SpellingKind, SpellingRegion};
pub use outline::merge_visibility;
use std::cell::RefCell;
@@ -165,6 +167,12 @@ impl Syntax {
}
pub fn outline(&self, lang: &LanguageId, text: &str) -> Vec<OutlineItem> {
// HTML containers (Vue SFC / Svelte / JSP have no grammar of their own;
// HTML itself parses alike): HTML outline with each inline `<script>`'s
// TS/JS declarations nested under its entry, in document coordinates.
if let Some(items) = self.web_outline(lang, text) {
return items;
}
self.analyze(lang, text, |g, tree, text| {
let query = g.outline_query()?;
Some(outline::outline(g, query, tree, text))
@@ -172,6 +180,20 @@ impl Syntax {
.unwrap_or_default()
}
/// Outline of an HTML container document (html, vue, svelte, jsp): `None`
/// for every other language, CSS containers included.
pub(crate) fn web_outline(&self, lang: &LanguageId, text: &str) -> Option<Vec<OutlineItem>> {
let g = self.web_grammar(lang)?;
if g.spec.name != "html" || text.len() > MAX_PARSE_BYTES {
return None;
}
let tree = parse(&g.language, text, None)?;
let query = g.outline_query()?;
Some(outline::outline_nested(g, query, &tree, text, &|region_lang, region_text| {
self.outline(region_lang, region_text)
}))
}
pub fn folding(&self, lang: &LanguageId, text: &str) -> Vec<FoldRange> {
let tokens = self.comment_tokens(lang);
self.analyze(lang, text, |g, tree, text| Some(folding::folding(g.spec, g.kinds(), tree, text, tokens)))
@@ -248,6 +270,19 @@ impl Syntax {
self.with_web_tree(lang, text, |_, _, tree| web::anchors(tree, text, name)).unwrap_or_default()
}
/// Inline TS/JS `<script>` regions of a Vue SFC / HTML document, in
/// document order. Empty for other languages or non-JavaScript blocks.
pub fn script_regions(&self, lang: &LanguageId, text: &str) -> Vec<ScriptRegion> {
self.with_web_tree(lang, text, |_g, _css, tree| sfc::script_regions(tree, text)).unwrap_or_default()
}
/// [`Self::script_regions`] of an already parsed document (`ParsedTree`
/// lends the tree and text) — the cache-aware way for other crates, which
/// reuses a cached incremental HTML tree instead of parsing again.
pub fn script_regions_of(tree: &Tree, text: &str) -> Vec<ScriptRegion> {
sfc::script_regions(tree, text)
}
/// Highlighting from the grammar's own query; `None` without one.
pub fn highlights(&self, lang: &LanguageId, text: &str) -> Option<SyntaxHighlights> {
self.analyze(lang, text, |g, tree, text| {
@@ -280,6 +315,13 @@ impl Syntax {
self.grammar(lang).map(|g| g.spec.name)
}
/// Name of the grammar web features parse `lang` with (html for Vue SFC /
/// Svelte / JSP, css for SCSS / Less); `None` when `lang` has its own
/// grammar or no web features. Lets other crates recognize containers.
pub fn web_grammar_name(&self, lang: &LanguageId) -> Option<&'static str> {
self.web_grammar(lang).map(|g| g.spec.name)
}
/// Grammar name and tree-sitter language for `lang`, for engines that parse
/// on their own (structural search patterns).
pub fn ts_language(&self, lang: &LanguageId) -> Option<(&'static str, Language)> {
+65 -2
Переглянути файл
@@ -13,9 +13,9 @@
//! macro event operator key heading impl extension.
use super::Grammar;
use super::lines::LineIndex;
use super::lines::{LineIndex, shift_range};
use crate::SymbolTag;
use ide_core::{OutlineItem, SymbolKind, Visibility};
use ide_core::{LanguageId, OutlineItem, Pos, SymbolKind, Visibility};
use std::collections::HashMap;
use tree_sitter::{Node, Query, QueryCursor, StreamingIterator, Tree};
@@ -296,6 +296,69 @@ pub(crate) fn outline(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) ->
collect(g, q, tree, text).into_iter().map(convert).collect()
}
/// [`outline`], plus, for the HTML grammar, each inline `<script>` region's
/// TS/JS declarations nested under its `script` entry and shifted to document
/// coordinates (Vue SFC / HTML documents). `block` outlines one region's
/// language, answering in region-local coordinates.
pub(crate) fn outline_nested(
g: &Grammar,
q: &OutlineQuery,
tree: &Tree,
text: &str,
block: &impl Fn(&LanguageId, &str) -> Vec<OutlineItem>,
) -> Vec<OutlineItem> {
let mut items = outline(g, q, tree, text);
if g.spec.name != "html" {
return items;
}
let lines = LineIndex::new(text);
for region in super::sfc::script_regions(tree, text) {
let mut decls = block(&region.language, &region.text);
if decls.is_empty() {
continue;
}
let base = lines.pos(region.base);
shift_outline(&mut decls, base);
if !attach_script(&mut items, base, &mut Some(decls)) {
// The region and the entry come from the same `script_element` node,
// so this means the outline query no longer matches script elements.
tracing::debug!("outline: no script entry for the <script> region at byte {}", region.base);
}
}
items
}
/// Recursively shifts region-local positions in `items` to document coordinates.
fn shift_outline(items: &mut [OutlineItem], base: Pos) {
for item in items {
item.range = shift_range(item.range, base);
item.selection_range = shift_range(item.selection_range, base);
shift_outline(&mut item.children, base);
}
}
/// Nests `decls` under the innermost `script` entry covering `base`. The
/// region and the entry both come from the same `script_element` node, so the
/// entry always exists; a miss means the outline query no longer matches it.
fn attach_script(items: &mut [OutlineItem], base: Pos, decls: &mut Option<Vec<OutlineItem>>) -> bool {
let covers = |range: &ide_core::Range| {
(range.start.line, range.start.col) <= (base.line, base.col)
&& (base.line, base.col) < (range.end.line, range.end.col)
};
for item in items.iter_mut() {
if item.name.eq_ignore_ascii_case("script") && covers(&item.range) {
item.children.extend(decls.take().unwrap_or_default());
return true;
}
}
for item in items.iter_mut() {
if attach_script(&mut item.children, base, decls) {
return true;
}
}
false
}
pub(crate) fn tags(g: &Grammar, q: &OutlineQuery, tree: &Tree, text: &str) -> Vec<SymbolTag> {
fn walk(items: Vec<Built>, container: Option<&str>, out: &mut Vec<SymbolTag>) {
for b in items {
+92
Переглянути файл
@@ -0,0 +1,92 @@
//! Inline TS/JS `<script>` regions located with the HTML grammar's
//! `script_element` nodes. The block's `lang` attribute selects the grammar.
//!
//! Blocks whose `type` attribute names anything but JavaScript are skipped
//! (`application/json`, `text/template`, `importmap`, …), as are unknown
//! `lang` values.
use super::web::walk;
use crate::ScriptRegion;
use ide_core::LanguageId;
use tree_sitter::{Node, Tree};
/// The TS/JS grammar a `lang` attribute value names; `None` skips the block.
fn lang_grammar(value: &str) -> Option<&'static str> {
match value {
"ts" | "typescript" => Some("typescript"),
"tsx" => Some("tsx"),
"" | "js" | "jsx" | "javascript" => Some("javascript"),
_ => None,
}
}
/// `true` when a `type` attribute value names JavaScript (or is absent).
fn is_js_type(value: &str) -> bool {
matches!(value, "" | "module" | "text/javascript" | "application/javascript")
}
/// The `<script>` regions of a document parsed with the HTML grammar,
/// in document order. `base` is each block's byte offset into the document.
pub(crate) fn script_regions(tree: &Tree, text: &str) -> Vec<ScriptRegion> {
let mut out = Vec::new();
walk(tree.root_node(), &mut |n| {
if n.kind() != "script_element" {
return true;
}
let mut lang = "javascript";
let mut skip = false;
let mut c = n.walk();
for ch in n.children(&mut c) {
match ch.kind() {
// The start tag precedes the raw text, so the attributes are
// known before the region is taken.
"start_tag" => {
let mut c2 = ch.walk();
for attr in ch.children(&mut c2) {
if attr.kind() != "attribute" {
continue;
}
let (name, value) = attribute(attr, text);
match name.to_ascii_lowercase().as_str() {
"lang" => match lang_grammar(&value.to_ascii_lowercase()) {
Some(g) => lang = g,
None => skip = true,
},
"type" if !is_js_type(&value.trim().to_ascii_lowercase()) => skip = true,
"src" => skip = true,
_ => {}
}
}
}
"raw_text" if !skip => {
out.push(ScriptRegion {
base: ch.start_byte(),
language: LanguageId::new(lang),
text: text[ch.start_byte()..ch.end_byte()].to_string(),
});
}
_ => {}
}
}
false // the raw text is not markup
});
out
}
/// An attribute's `(name, value)`; a bare attribute (`setup`) has an empty value.
fn attribute<'t>(attr: Node<'t>, text: &'t str) -> (&'t str, &'t str) {
let mut c = attr.walk();
let mut name = "";
let mut value = "";
for ch in attr.children(&mut c) {
match ch.kind() {
"attribute_name" => name = ch.utf8_text(text.as_bytes()).unwrap_or(""),
"quoted_attribute_value" => {
value = ch.named_child(0).and_then(|v| v.utf8_text(text.as_bytes()).ok()).unwrap_or("");
}
"attribute_value" => value = ch.utf8_text(text.as_bytes()).unwrap_or(""),
_ => {}
}
}
(name, value)
}
+229
Переглянути файл
@@ -759,6 +759,7 @@ fn outline_html_css_xml() {
Field body
Field div
Field script
Variable a
",
);
check_outline(
@@ -1409,3 +1410,231 @@ fn spelling_regions_cover_declared_names_only() {
.collect();
assert_eq!(names, ["Bird", "fly", "text"]);
}
// ---------------------------------------------------------------------------
// Script regions of Vue SFCs and HTML documents
#[test]
fn script_regions_skip_typed_blocks() {
let src = "<html><body><script type=\"module\">var x = 1;</script>\
<script type=\"application/json\">{\"a\":1}</script>\
<script type=\"importmap\">{}</script>\
<script>var y = 2;</script></body></html>";
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 = "<script setup lang=\"tsx\">\nconst App = () => <div/>\n</script>\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"), "<template><div/></template>").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!("<script {attributes}>const value = 1;</script>");
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 = "<template>中文😀</template>\r\n<script lang=ts>const 名 = '😀';\r\n</script>\r\n<script setup lang=jsx>const View = () => <div/>;</script>";
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 = () => <div/>;", 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 = "<template>中文😀</template><script>let x = 1;</script>";
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 [
"<script",
"<script lang=",
"<script lang='ts",
"<script>const unfinished =",
"<template><div><script lang=ts>const x = 1;</script>",
"<script></script>",
"<script src='missing.js'>ignored</script><script>const x = 1;",
] {
for region in syntax().script_regions(&lang("vue"), src) {
assert_eq!(src.get(region.base..region.base + region.text.len()), Some(region.text.as_str()));
}
}
}
#[test]
fn script_regions_large_vue_document() {
for count in [2_000, 4_000, 8_000] {
let src =
"<template><div>中文😀</div></template>\r\n<script setup lang=ts>const x = 1;</script>\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 = "<div>".repeat(1_000) + "<script>const x = 1;</script>";
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 <script> declarations (Vue SFC / HTML)
#[test]
fn html_outline_nests_script_declarations() {
let src = "<html><body>\n<script>\nfunction boot() {}\nconst app = 1;\n</script>\n\
<script type=\"application/json\">{\"a\": 1}</script>\n</body></html>";
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 = "<template>\n <div class=\"app\"/>\n</template>\n\
<script setup lang=\"ts\">\ninterface Props { size: number }\nfunction scale(n: number) {}\n</script>\n\
<style scoped>\n.app { color: red }\n</style>";
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 = "<template><!-- 😀 --></template>\r\n<script lang=\"ts\">\r\nconst answer = 42;\r\n</script>";
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 = "<template><div/></template>\n<script lang=\"ts\">\nconst app = 1;\n</script>\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 = "<template>中文😀</template><script lang=ts>/* 中文😀 */ function first() { return 1; }</script>\r\n<script setup lang=ts>/* 中文😀 */ const second = 2;\r\nfunction third() {}\r\n</script>";
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", "<SCRIPT>function boot() {}</SCRIPT>", "Field SCRIPT\n Function boot");
}
+14 -7
Переглянути файл
@@ -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;
}
}
}
}
+113 -3
Переглянути файл
@@ -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 <script> regions add the block
// grammar's tables — TypeScript / JavaScript ship locals.scm — so both
// capabilities are on whenever HTML is.
let g =
syn.grammar_name(language).or_else(|| (syn.web_grammar_name(language) == Some("html")).then_some("html"));
match g {
Some(g) => StructureSupport { tree: true, locals: kinds::locals_source(g).is_some() || g == "html" },
None => StructureSupport::default(),
}
}
@@ -157,6 +164,17 @@ impl StructureService {
range: Range,
cancel: &CancellationToken,
) -> StructureResult<Option<NodeAt>> {
let bytes = Lines::new(snap.text).bytes(range);
if let Some(at) = self.with_script_tree(snap, bytes, cancel, |cx, local, base| {
Ok(nodes::node_at(cx, local).map(|at| shift_node_at(at, base)))
})? {
return Ok(at);
}
// Outside any <script> region a web container (template markup) has no
// identifier to offer: an empty answer, not an error.
if self.web_container(snap.language) {
return Ok(None);
}
self.run(snap, cancel, |cx| Ok(nodes::node_at(cx, range)))
}
@@ -178,6 +196,17 @@ impl StructureService {
/// Scope analysis for the identifier at `pos` (`locals.scm`).
pub fn locals(&self, snap: &Snapshot, pos: Pos, cancel: &CancellationToken) -> StructureResult<Locals> {
let byte = Lines::new(snap.text).byte(pos);
if let Some(locals) = self.with_script_tree(snap, (byte, byte), cancel, |cx, local, base| {
locals::locals(cx, local.start).map(|l| shift_locals(l, base))
})? {
return Ok(locals);
}
if self.web_container(snap.language) {
// Template markup: no TypeScript identifier here; the frontend
// treats `noIdentifier` as "nothing at this position".
return Ok(Locals::NoIdentifier);
}
self.run(snap, cancel, |cx| locals::locals(cx, pos))
}
@@ -255,6 +284,62 @@ impl StructureService {
Ok(ranges)
}
/// Whether `language` is an HTML container without a grammar of its own
/// (Vue SFC / Svelte / JSP): structure answers come from the HTML tree
/// plus the inline `<script>` regions' block grammars.
fn web_container(&self, language: &LanguageId) -> bool {
let syn = self.cache.syntax();
syn.grammar_name(language).is_none() && syn.web_grammar_name(language) == Some("html")
}
/// Runs `f` on the block grammar's tree when one of the document's inline
/// `<script>` regions covers `bytes`; `f` sees the region's `Ctx`, the
/// query converted into region coordinates, and the region's document
/// `base`. `Ok(None)` when no region covers `bytes`.
fn with_script_tree<R>(
&self,
snap: &Snapshot,
bytes: (usize, usize),
cancel: &CancellationToken,
f: impl FnOnce(&Ctx, Range, Pos) -> StructureResult<R>,
) -> StructureResult<Option<R>> {
if cancel.is_cancelled() {
return Err(StructureError::Cancelled);
}
let cancelled = || cancel.is_cancelled();
let syn = self.cache.syntax();
if syn.web_grammar_name(snap.language) != Some("html") {
return Ok(None);
}
let extract = |p: ParsedTree<'_>| Syntax::script_regions_of(p.tree, p.text);
let regions = match snap.doc {
Some(doc) if syn.grammar_name(snap.language).is_some() => {
self.cache.with_parsed(doc, snap.language, snap.version, snap.text, &cancelled, extract)
}
_ => syn.with_parsed(&LanguageId::new("html"), snap.text, &cancelled, extract),
};
let Some(region) = regions?.into_iter().find(|r| bytes.0 >= r.base && bytes.1 <= r.base + r.text.len()) else {
return Ok(None);
};
let base = Lines::new(snap.text).pos(region.base);
let (a, b) = (bytes.0 - region.base, bytes.1 - region.base);
syn.with_parsed(&region.language, &region.text, &cancelled, |p| {
let cx = Ctx {
grammar: p.grammar,
language: p.language,
tree: p.tree,
kinds: Kinds::for_grammar(p.grammar, p.language),
lines: Lines::new(p.text),
cancel,
};
let local = Range::new(cx.lines.pos(a), cx.lines.pos(b));
f(&cx, local, base)
})
.map_err(StructureError::from)
.and_then(|r| r)
.map(Some)
}
fn run<R>(
&self,
snap: &Snapshot,
@@ -288,3 +373,28 @@ fn utf16_offset(text: &str, pos: Pos) -> u32 {
let lines = Lines::new(text);
lines::utf16_len(&text[..lines.byte(pos)])
}
/// Shifts a `<script>` region's answer to document coordinates (`base` is the
/// region start): the node and every ancestor move by [`shift_range`].
fn shift_node_at(at: NodeAt, base: Pos) -> NodeAt {
let shift = |n: SyntaxNode| SyntaxNode { range: shift_range(n.range, base), ..n };
NodeAt { node: shift(at.node), ancestors: at.ancestors.into_iter().map(shift).collect() }
}
/// [`shift_node_at`] for a `locals` answer: every range moves (`NotLocal`'s,
/// the symbol's definition, scope, references and conflicts), names stay.
fn shift_locals(l: Locals, base: Pos) -> Locals {
match l {
Locals::NoIdentifier => Locals::NoIdentifier,
Locals::NotLocal { name, range, reason } => Locals::NotLocal { name, range: shift_range(range, base), reason },
Locals::Local { symbol } => Locals::Local {
symbol: LocalSymbol {
definition: shift_range(symbol.definition, base),
scope: shift_range(symbol.scope, base),
references: symbol.references.into_iter().map(|r| shift_range(r, base)).collect(),
conflicts: symbol.conflicts.into_iter().map(|r| shift_range(r, base)).collect(),
..symbol
},
},
}
}
+120
Переглянути файл
@@ -287,3 +287,123 @@ fn identifiers_in_files_loads_in_parallel_and_skips_misses() {
let got: Vec<(String, usize)> = found.iter().map(|f| (f.path.display().to_string(), f.occurrences.len())).collect();
assert_eq!(got, [("/p/a.ts".to_owned(), 1), ("/p/d.rs".to_owned(), 2)]);
}
// ---------------------------------------------------------------------------
// Vue SFC / HTML: answers from the inline <script> regions' block grammars
#[test]
fn web_containers_support_the_tree_and_locals() {
let s = service();
for id in ["vue", "html"] {
let support = s.support(&lang(id));
assert!(support.tree, "{id}");
assert!(support.locals, "{id}");
}
let rust = s.support(&lang("rust"));
assert!(rust.tree && rust.locals, "languages with their own grammar are unchanged");
let plain = s.support(&lang("plaintext"));
assert!(!plain.tree && !plain.locals);
}
#[test]
fn node_at_inside_a_script_region_answers_from_the_block_grammar() {
let src = "<template>\n <div>{{ n }}</div>\n</template>\n<script lang=\"ts\">\nconst size = 1;\n</script>\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 = "<template><div/></template>\n<script lang=\"ts\">\nfunction f() {\n let count = 0;\n count += 1;\n}\n</script>\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 = "<template>中文😀</template><script>let value = 1;</script>";
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 = "<template>中文😀</template><script lang=ts>/* 中文😀 */ function first() { let 名 = '😀'; 名; }</script>\r\n<script setup lang=ts>function second() {\r\nlet 名 = 2;\r\n名;\r\n}</script>";
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("<script>function f() { let x = 1; x; }</script>");
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));
}
}
+11
Переглянути файл
@@ -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 <script> declarations", () => {
useOutline.setState({
[PATH]: [item("template", 0, 4), { ...item("script", 5, 30), children: [item("size", 10, 20)] }],
});
act(() => useEditorStatus.setState({ status: caret(12, 2) }));
render(<Breadcrumbs />);
const crumbs = screen.getByRole("navigation", { name: "Breadcrumbs" }).textContent!;
expect(crumbs).toContain("script");
expect(crumbs).toContain("size");
});
});
it("shows the member and its directories in workspace breadcrumbs", async () => {
+38
Переглянути файл
@@ -218,6 +218,21 @@ describe("declarationFallback", () => {
kind: "unsupported",
});
});
it("answers a Vue component from the <script> region's shifted coordinates", async () => {
cmd.syntaxSupport.mockReturnValueOnce(ok({ tree: true, locals: true }));
// The backend answers a script region's local in document coordinates.
cmd.syntaxLocals.mockReturnValueOnce(ok(local({ name: "size", definition: r(9, 6, 10) })));
const res = await fb.declarationFallback({ ...doc, path: "/p/App.vue", language: "vue" }, text, {
line: 9,
col: 6,
});
expect(res).toEqual({
kind: "targets",
targets: [{ path: "/p/App.vue", range: r(9, 6, 10), name: "size" }],
ambiguous: false,
});
});
});
describe("rename helpers", () => {
@@ -266,6 +281,15 @@ describe("rename helpers", () => {
expect(cmd.syntaxNodeAt).not.toHaveBeenCalled();
});
it("plans a Vue component's local rename with shorthand detection", async () => {
cmd.syntaxLocals.mockReturnValueOnce(ok(local()));
const short = ok({ node: node("shorthand_property_identifier", r(1, 2, 3)), ancestors: [] });
cmd.syntaxNodeAt.mockReturnValueOnce(short);
cmd.syntaxNodeAt.mockReturnValueOnce(short);
const plan = await fb.planLocalRename({ doc: 7, language: "vue" }, { line: 1, col: 2 });
expect(plan.kind === "plan" && plan.plan.occurrences.map((o) => o.shorthand)).toEqual([true, true]);
});
it("flags ambiguity and name clashes inside the scope", async () => {
const plan = {
symbol: (local({ certain: false, conflicts: [r(3, 0, 1)] }) as Extract<Locals, { status: "local" }>).symbol,
@@ -295,6 +319,20 @@ describe("rename helpers", () => {
});
describe("navigation wiring", () => {
it.each(["vue", "html", "svelte", "jsp"])("keeps %s server answers ahead of syntax", async (language) => {
const target = { path: `/p/a.${language}`, range: r(0, 6, 7), uri: null };
const view = editor("const x = 1;\nx;", language, 13, target.path);
cmd.lspGoto.mockReturnValueOnce(ok([target]));
await gotoTargets(view, "definition");
expect(openFile).toHaveBeenCalledWith(target.path, { selection: target.range.start, focus: true });
expect(cmd.syntaxLocals).not.toHaveBeenCalled();
expect(cmd.syntaxNodeAt).not.toHaveBeenCalled();
cmd.lspGoto.mockReturnValueOnce(ok([]));
await gotoTargets(view, "definition");
expect(openFile).toHaveBeenCalledOnce();
expect(cmd.syntaxLocals).not.toHaveBeenCalled();
});
it("respects an empty server answer", async () => {
cmd.lspGoto.mockReturnValueOnce(ok([]));
const view = editor("const x = 1;\nx;", "typescript", 13);
+13 -2
Переглянути файл
@@ -199,8 +199,19 @@ export function localHighlights(locals: Locals): LocalHighlight[] | null {
// ---------------------------------------------------------------------------
// Rename
/** Languages whose shorthand fields (`{ x }`, `S { x }`) name both a key and a variable. */
const SHORTHAND_LANGUAGES = new Set(["javascript", "javascriptreact", "typescript", "typescriptreact", "rust"]);
/** Languages whose shorthand fields (`{ x }`, `S { x }`) name both a key and a variable;
* the web containers host the same TS/JS inside their `<script>` blocks. */
const SHORTHAND_LANGUAGES = new Set([
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"rust",
"vue",
"svelte",
"html",
"jsp",
]);
const SHORTHAND_NODES = new Set([
"shorthand_property_identifier",
"shorthand_property_identifier_pattern",
+16
Переглянути файл
@@ -60,6 +60,22 @@ describe("File Structure popup", () => {
expect(rows()).toEqual(["OrderController", "list", "create", "helper"]);
});
it("shows a Vue component's script declarations nested under its script block", () => {
useOutline.setState({
path: "/p/App.vue",
doc: 1,
loading: false,
error: null,
items: [
item("template", "module", 0),
{ ...item("script", "module", 4), children: [item("App", "function", 8), item("size", "variable", 12)] },
],
});
render(<FileStructureOverlay />);
act(() => useFileStructurePopup.setState({ open: true }));
expect(rows()).toEqual(["template", "script", "App", "size"]);
});
it("Esc clears the filter first, then closes", () => {
const input = open();
fireEvent.change(input, { target: { value: "help" } });