дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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]>
Цей коміт міститься в:
1 джерело
226ac26954
коміт
29af5994e3
14 files changed
+835
-19
No files matched your search
@@ -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,
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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(®ion.language, ®ion.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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(®ion.language, ®ion.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
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
Reference in new issue
Заблокувати користувача