Compare commits

...
Author SHA1 Message Date
l0ng-ai 711886e68d fix(vue): go to the ref and its component's members from this.$refs
Vue 2.7 types `$refs` as an index signature, so the TypeScript service sends
`this.$refs.child` to that signature in vue.d.ts and finds nothing for
`this.$refs.child.focusIt`.

Go to Declaration in a Vue SFC's script now reads the template instead:
`this.$refs.<name>` goes to the `ref="<name>"` attribute (the last one, as Vue
keeps), and a member read from a component's ref goes to that member in the
component's options (methods, computed, props, data() / setup() return). The
component comes from the default import its tag names; relative paths resolve
directly, aliases through the language service, then `@/` as `src/`. Anything
else still goes to the language servers.
2026-11-12 23:36:30 +08:00
6 changed files with 511 additions and 3 deletions

No files matched your search

+3 -2
View File
@@ -24,8 +24,9 @@ pub use registry::{FileTypes, LanguageRegistry};
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, shift_pos, shift_range,
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, VueComponentImport,
VueRefAccess, VueRefElement, WebReference, WebReferenceKind, merge_visibility, set_external_syntax, shift_pos,
shift_range,
};
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
+2
View File
@@ -12,6 +12,7 @@ mod outline;
mod rst;
mod selection;
mod sfc;
mod vue;
mod web;
use crate::{FoldRange, LanguageConfig, LanguageRegistry, ScriptRegion, SymbolTag};
@@ -28,6 +29,7 @@ use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::OnceLock;
use tree_sitter::{Language, Parser, Tree};
pub use vue::{VueComponentImport, VueRefAccess, VueRefElement};
pub use web::{CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, WebReference, WebReferenceKind};
/// Texts larger than this are not parsed (minified bundles, generated dumps).
+370
View File
@@ -0,0 +1,370 @@
//! Vue Options API `this.$refs`: Vue 2.7 types `$refs` as an index signature, so the
//! TypeScript service resolves `this.$refs.child` to that signature and `.child.focusIt`
//! to nothing. The template names each ref (`ref="child"`) and the element it is on,
//! which is all navigation needs.
use super::lines::{LineIndex, byte_at};
use super::web::walk;
use super::{Syntax, parse};
use crate::ScriptRegion;
use ide_core::{LanguageId, Pos, Range};
use tree_sitter::Node;
/// The `$refs.<name>` (or `$refs.<name>.<member>`) expression a position is on.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VueRefAccess {
pub name: String,
/// The member read from the referenced element, when the position is on it.
pub member: Option<String>,
}
/// The template element that carries `ref="<name>"`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VueRefElement {
/// The attribute value: the ref's declaration.
pub value: Range,
pub tag: String,
}
/// A default import in a component's `<script>`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VueComponentImport {
pub specifier: String,
/// The imported name, where the language service resolves the module.
pub binding: Range,
}
/// Options whose members are on the component instance.
const INSTANCE_OPTIONS: [&str; 5] = ["methods", "computed", "props", "data", "setup"];
impl Syntax {
/// The `$refs` access at `pos` in a Vue SFC's `<script>`.
pub fn vue_ref_access(&self, text: &str, pos: Pos) -> Option<VueRefAccess> {
let at = byte_at(text, pos)?;
let region = self.script_regions(&LanguageId::new("vue"), text).into_iter().find(|r| contains(r, at))?;
let g = self.grammar(&region.language)?;
let tree = parse(&g.language, &region.text, None)?;
let rel = at - region.base;
let root = tree.root_node();
let node = [rel, rel.saturating_sub(1)]
.into_iter()
.filter_map(|b| root.descendant_for_byte_range(b, b))
.find(|n| n.kind() == "property_identifier")?;
let src = region.text.as_str();
let read = node.parent().filter(|p| p.kind() == "member_expression")?;
let object = read.child_by_field_name("object")?;
if is_refs(object, src) {
return Some(VueRefAccess { name: text_of(node, src).to_owned(), member: None });
}
if object.kind() == "member_expression"
&& let Some(refs) = object.child_by_field_name("object")
&& is_refs(refs, src)
&& let Some(name) = object.child_by_field_name("property")
{
return Some(VueRefAccess {
name: text_of(name, src).to_owned(),
member: Some(text_of(node, src).to_owned()),
});
}
None
}
/// The element with `ref="<name>"` in a Vue SFC's template; the last one when several
/// share the name, as Vue keeps the last.
pub fn vue_ref_element(&self, text: &str, name: &str) -> Option<VueRefElement> {
let g = self.web_grammar(&LanguageId::new("vue"))?;
let tree = parse(&g.language, text, None)?;
let idx = LineIndex::new(text);
let mut found = None;
walk(tree.root_node(), &mut |n| {
if n.kind() != "attribute" {
return !matches!(n.kind(), "script_element" | "style_element");
}
if n.child(0).is_some_and(|a| text_of(a, text) == "ref")
&& let Some(value) = attribute_value(n)
&& text_of(value, text) == name
&& let Some(tag) = n.parent().and_then(|t| t.child(1)).filter(|t| t.kind() == "tag_name")
{
found = Some(VueRefElement {
value: idx.range(value.start_byte(), value.end_byte()),
tag: text_of(tag, text).to_owned(),
});
}
false
});
found
}
/// The default import in a Vue SFC's `<script>` that a template tag names
/// (`<child-button>` / `<ChildButton>` → `import ChildButton from …`).
pub fn vue_component_import(&self, text: &str, tag: &str) -> Option<VueComponentImport> {
let wanted = component_key(tag);
let idx = LineIndex::new(text);
self.script_regions(&LanguageId::new("vue"), text).into_iter().find_map(|region| {
let g = self.grammar(&region.language)?;
let tree = parse(&g.language, &region.text, None)?;
let src = region.text.as_str();
let mut c = tree.root_node().walk();
tree.root_node().children(&mut c).filter(|n| n.kind() == "import_statement").find_map(|n| {
let clause = n.children(&mut n.walk()).find(|ch| ch.kind() == "import_clause")?;
let binding = clause.children(&mut clause.walk()).find(|ch| ch.kind() == "identifier")?;
if component_key(text_of(binding, src)) != wanted {
return None;
}
let source = n.child_by_field_name("source")?;
let specifier = source.named_child(0).map(|f| text_of(f, src)).unwrap_or_default();
Some(VueComponentImport {
specifier: specifier.to_owned(),
binding: idx.range(region.base + binding.start_byte(), region.base + binding.end_byte()),
})
})
})
}
/// Where a component declares the instance member `name` in its options object
/// (`methods`, `computed`, `props`, `data()` / `setup()` return). `lang` is `vue` for an
/// SFC, or the script language of a component written as a plain module.
pub fn vue_component_member(&self, lang: &LanguageId, text: &str, name: &str) -> Option<Range> {
let regions = if lang.as_str() == "vue" {
self.script_regions(lang, text)
} else {
vec![ScriptRegion { base: 0, language: lang.clone(), text: text.to_owned() }]
};
let idx = LineIndex::new(text);
regions.into_iter().find_map(|region| {
let g = self.grammar(&region.language)?;
let tree = parse(&g.language, &region.text, None)?;
let src = region.text.as_str();
let options = default_options(tree.root_node(), src)?;
let key = instance_member(options, src, name)?;
Some(idx.range(region.base + key.start_byte(), region.base + key.end_byte()))
})
}
}
fn contains(region: &ScriptRegion, at: usize) -> bool {
region.base <= at && at <= region.base + region.text.len()
}
fn text_of<'t>(n: Node, text: &'t str) -> &'t str {
n.utf8_text(text.as_bytes()).unwrap_or("")
}
/// `<anything>.$refs`.
fn is_refs(n: Node, src: &str) -> bool {
n.kind() == "member_expression" && n.child_by_field_name("property").is_some_and(|p| text_of(p, src) == "$refs")
}
fn attribute_value(attr: Node) -> Option<Node> {
let value =
attr.children(&mut attr.walk()).find(|ch| matches!(ch.kind(), "quoted_attribute_value" | "attribute_value"))?;
if value.kind() == "attribute_value" { Some(value) } else { value.named_child(0) }
}
/// Vue matches a tag to a registered component in kebab-case or PascalCase.
fn component_key(name: &str) -> String {
name.chars().filter(|c| *c != '-').flat_map(char::to_lowercase).collect()
}
/// The options object of `export default { … }`, `export default defineComponent({ … })`
/// or `export default Vue.extend({ … })`.
fn default_options<'t>(root: Node<'t>, src: &str) -> Option<Node<'t>> {
let mut c = root.walk();
let export = root.children(&mut c).find(|n| {
n.kind() == "export_statement" && n.children(&mut n.walk()).any(|ch| text_of(ch, src) == "default")
})?;
let value = export.child_by_field_name("value").or_else(|| export.child_by_field_name("declaration"))?;
object_of(value)
}
fn object_of(n: Node) -> Option<Node> {
match n.kind() {
"object" => Some(n),
"parenthesized_expression" | "as_expression" | "satisfies_expression" => object_of(n.named_child(0)?),
"call_expression" => object_of(n.child_by_field_name("arguments")?.named_child(0)?),
_ => None,
}
}
fn instance_member<'t>(options: Node<'t>, src: &str, name: &str) -> Option<Node<'t>> {
for option in INSTANCE_OPTIONS {
let Some(entry) = entry(options, src, option) else { continue };
let found = match option {
"data" | "setup" => returned_objects(entry).into_iter().find_map(|o| key(o, src, name)),
"props" => match entry.child_by_field_name("value") {
Some(array) if array.kind() == "array" => array
.named_children(&mut array.walk())
.find(|s| s.kind() == "string" && string_value(*s, src) == name),
value => value.and_then(object_of).and_then(|o| key(o, src, name)),
},
_ => entry.child_by_field_name("value").and_then(object_of).and_then(|o| key(o, src, name)),
};
if found.is_some() {
return found;
}
}
None
}
/// The `pair` or `method_definition` of `object` named `name`.
fn entry<'t>(object: Node<'t>, src: &str, name: &str) -> Option<Node<'t>> {
object.named_children(&mut object.walk()).find(|n| {
matches!(n.kind(), "pair" | "method_definition")
&& n.child_by_field_name(if n.kind() == "pair" { "key" } else { "name" })
.is_some_and(|k| property_name(k, src) == name)
})
}
/// The key node of `object`'s member `name`, shorthand properties included.
fn key<'t>(object: Node<'t>, src: &str, name: &str) -> Option<Node<'t>> {
object.named_children(&mut object.walk()).find_map(|n| match n.kind() {
"pair" => n.child_by_field_name("key").filter(|k| property_name(*k, src) == name),
"method_definition" => n.child_by_field_name("name").filter(|k| property_name(*k, src) == name),
"shorthand_property_identifier" => (text_of(n, src) == name).then_some(n),
_ => None,
})
}
fn property_name<'t>(key: Node, src: &'t str) -> &'t str {
if key.kind() == "string" { string_value(key, src) } else { text_of(key, src) }
}
fn string_value<'t>(s: Node, src: &'t str) -> &'t str {
s.named_child(0).map(|f| text_of(f, src)).unwrap_or("")
}
/// The objects a `data()` / `setup()` option returns (`data: () => ({ … })` included).
fn returned_objects(entry: Node) -> Vec<Node> {
let function = if entry.kind() == "method_definition" { Some(entry) } else { entry.child_by_field_name("value") };
let Some(function) = function else { return Vec::new() };
let Some(body) = function.child_by_field_name("body") else { return Vec::new() };
if let Some(object) = object_of(body) {
return vec![object];
}
let mut out = Vec::new();
walk(body, &mut |n| match n.kind() {
"return_statement" => {
out.extend(n.named_child(0).and_then(object_of));
false
}
// A nested function's `return` is not the option's.
"function_expression" | "arrow_function" | "function_declaration" | "method_definition" | "class_body" => false,
_ => true,
});
out
}
#[cfg(test)]
mod tests {
use super::*;
const APP: &str = r#"<template>
<div>
<child-button ref="child" @my-event="handler" />
<input ref='field'>
<span>{{ $refs.child }}</span>
</div>
</template>
<script>
import ChildButton from './components/ChildButton.vue'
export default {
name: 'App',
components: { ChildButton },
methods: {
handler() {
this.$refs.child
this.$refs.child.focusIt()
this.$refs.field?.focus()
}
}
}
</script>
"#;
fn pos_of(text: &str, needle: &str, nth: usize, offset: usize) -> Pos {
let byte = text.match_indices(needle).nth(nth).unwrap().0 + offset;
LineIndex::new(text).pos(byte)
}
#[test]
fn finds_the_ref_and_the_member_read_from_it() {
let s = Syntax::new();
let child = s.vue_ref_access(APP, pos_of(APP, "this.$refs.child", 0, 12)).unwrap();
assert_eq!(child, VueRefAccess { name: "child".into(), member: None });
// At the end of the name too.
let end = s.vue_ref_access(APP, pos_of(APP, "this.$refs.child", 0, 16)).unwrap();
assert_eq!(end.name, "child");
let member = s.vue_ref_access(APP, pos_of(APP, "focusIt", 0, 2)).unwrap();
assert_eq!(member, VueRefAccess { name: "child".into(), member: Some("focusIt".into()) });
let optional = s.vue_ref_access(APP, pos_of(APP, "field?.focus", 0, 1)).unwrap();
assert_eq!(optional.name, "field");
assert!(s.vue_ref_access(APP, pos_of(APP, "$refs.child", 1, 2)).is_none(), "$refs itself");
assert!(s.vue_ref_access(APP, pos_of(APP, "handler", 1, 1)).is_none());
assert!(s.vue_ref_access(APP, pos_of(APP, "$refs.child }}", 0, 7)).is_none(), "template is the server's");
}
#[test]
fn finds_the_element_with_the_ref() {
let s = Syntax::new();
let child = s.vue_ref_element(APP, "child").unwrap();
assert_eq!(child.tag, "child-button");
assert_eq!(child.value.start, pos_of(APP, "\"child\"", 0, 1));
let field = s.vue_ref_element(APP, "field").unwrap();
assert_eq!(field.tag, "input");
assert!(s.vue_ref_element(APP, "missing").is_none());
let last = "<template><a ref=\"x\"/><b ref=\"x\"></b></template>";
assert_eq!(s.vue_ref_element(last, "x").unwrap().tag, "b");
assert!(s.vue_ref_element("<template><a :ref=\"x\"/></template>", "x").is_none());
}
#[test]
fn finds_the_import_a_tag_names() {
let s = Syntax::new();
let import = s.vue_component_import(APP, "child-button").unwrap();
assert_eq!(import.specifier, "./components/ChildButton.vue");
assert_eq!(import.binding.start, pos_of(APP, "ChildButton from", 0, 0));
assert_eq!(s.vue_component_import(APP, "ChildButton").unwrap().specifier, import.specifier);
assert!(s.vue_component_import(APP, "input").is_none());
}
#[test]
fn finds_instance_members_in_the_options() {
let s = Syntax::new();
let child = r#"<template><button /></template>
<script>
export default {
name: 'ChildButton',
props: ['label', 'size'],
data() {
const helper = () => { return { nope: 1 } }
return { count: 0, helper }
},
computed: { double() { return this.count * 2 }, 'quoted': function () {} },
methods: {
focusIt() { this.$el.focus() },
reset: function () {},
}
}
</script>
"#;
let vue = LanguageId::new("vue");
let at = |name: &str| s.vue_component_member(&vue, child, name);
assert_eq!(at("focusIt").unwrap().start, pos_of(child, "focusIt", 0, 0));
assert_eq!(at("reset").unwrap().start, pos_of(child, "reset", 0, 0));
assert_eq!(at("double").unwrap().start, pos_of(child, "double", 0, 0));
assert_eq!(at("quoted").unwrap().start, pos_of(child, "'quoted'", 0, 0));
assert_eq!(at("size").unwrap().start, pos_of(child, "'size'", 0, 0));
assert_eq!(at("count").unwrap().start, pos_of(child, "count: 0", 0, 0));
assert_eq!(at("helper").unwrap().start, pos_of(child, "helper }", 0, 0));
assert!(at("nope").is_none());
assert!(at("name").is_none());
let ts = "import { defineComponent } from 'vue'\nexport default defineComponent({ methods: { go() {} } })\n";
let at = s.vue_component_member(&LanguageId::new("typescript"), ts, "go").unwrap();
assert_eq!(at.start, pos_of(ts, "go()", 0, 0));
let props = "export default { props: { value: String } }";
assert!(s.vue_component_member(&LanguageId::new("javascript"), props, "value").is_some());
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ struct Response {
#[serde(default)]
incomplete: bool,
}
fn syntax() -> &'static Syntax {
pub(crate) fn syntax() -> &'static Syntax {
static SYNTAX: OnceLock<Syntax> = OnceLock::new();
SYNTAX.get_or_init(Syntax::new)
}
+4
View File
@@ -37,6 +37,7 @@ pub mod types;
mod uri;
mod virtual_doc;
mod vue_navigation;
mod vue_refs;
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
pub use inspect::{FileInspection, InspectHost, InspectOptions};
@@ -1602,6 +1603,9 @@ impl LspManager {
{
return Ok(result.locations);
}
if let Some(found) = self.vue_ref_navigation(doc, &snap, pos).await {
return Ok(found);
}
}
let (method, cap, feature) = goto_method(kind);
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
+131
View File
@@ -0,0 +1,131 @@
//! Vue Options API `this.$refs`: the Vue 2.7 types declare `$refs` as an index signature,
//! so the TypeScript service sends `this.$refs.child` to that signature and finds nothing
//! for `this.$refs.child.focusIt`. The template says which element each ref is on:
//! `this.$refs.child` goes to its `ref="child"`, and a member read from a component's ref
//! goes to that member in the component's options.
use crate::jdtls::configuration::syntax;
use crate::types::GotoKind;
use crate::{DocSnap, LspManager, goto_method};
use ide_core::{DocId, LanguageId, Location, Pos};
use std::path::{Component, Path, PathBuf};
impl LspManager {
pub(crate) async fn vue_ref_navigation(&self, doc: DocId, snap: &DocSnap, pos: Pos) -> Option<Vec<Location>> {
if snap.language.as_str() != "vue" || !snap.text.as_str().contains("$refs") {
return None;
}
let text = snap.text.clone();
let (member, element, import) = tokio::task::spawn_blocking(move || {
let s = syntax();
let access = s.vue_ref_access(text.as_str(), pos)?;
let element = s.vue_ref_element(text.as_str(), &access.name)?;
let import = access.member.as_ref().and_then(|_| s.vue_component_import(text.as_str(), &element.tag));
Some((access.member, element, import))
})
.await
.ok()
.flatten()?;
let Some(member) = member else {
return Some(vec![Location { path: snap.path.clone().into(), range: element.value, uri: None }]);
};
let import = import?;
let file = match relative_module(&snap.path, &import.specifier) {
Some(file) => file,
// An alias (`@/components/…`): the language service knows the project's paths;
// without them configured, `@` is the Vue CLI / Vite templates' `src`.
None => match self.definition_file(doc, import.binding.start).await {
Some(file) => file,
None => src_alias(&self.project_root_for(&snap.path), &import.specifier)?,
},
};
let lang = script_language(&file)?;
let open = self.docs.lock().values().find(|d| d.path == file).map(|d| d.text.clone());
let range = tokio::task::spawn_blocking({
let file = file.clone();
move || {
let text = match open {
Some(text) => text.as_str().to_owned(),
None => std::fs::read_to_string(&file).ok()?,
};
syntax().vue_component_member(&lang, &text, &member)
}
})
.await
.ok()
.flatten()?;
Some(vec![Location { path: file.into(), range, uri: None }])
}
async fn definition_file(&self, doc: DocId, pos: Pos) -> Option<PathBuf> {
let (method, cap, feature) = goto_method(GotoKind::Definition);
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await.ok()?;
let found = self.goto_in(&snap, &targets, method, pos, None, None).await.ok()?;
// The import binding itself is no answer.
found.into_iter().find(|l| l.uri.is_none() && *l.path != snap.path).map(|l| l.path.into_path_buf())
}
}
/// The file a relative import names, with the extensions a bundler tries.
fn relative_module(from: &Path, specifier: &str) -> Option<PathBuf> {
if !specifier.starts_with("./") && !specifier.starts_with("../") {
return None;
}
let mut base = from.parent()?.to_path_buf();
for part in Path::new(specifier).components() {
match part {
Component::CurDir => {}
Component::ParentDir => {
base.pop();
}
part => base.push(part),
}
}
let candidates = [String::new(), ".vue".into(), ".ts".into(), ".js".into()]
.into_iter()
.map(|ext| PathBuf::from(format!("{}{ext}", base.display())))
.chain(["index.vue", "index.ts", "index.js"].into_iter().map(|index| base.join(index)));
candidates.into_iter().find(|p| p.is_file())
}
fn src_alias(root: &Path, specifier: &str) -> Option<PathBuf> {
let rest = specifier.strip_prefix("@/")?;
relative_module(&root.join("src").join("_"), &format!("./{rest}"))
}
fn script_language(file: &Path) -> Option<LanguageId> {
let id = match file.extension()?.to_str()? {
"vue" => "vue",
"ts" | "mts" | "cts" => "typescript",
"tsx" => "tsx",
"js" | "mjs" | "cjs" | "jsx" => "javascript",
_ => return None,
};
Some(LanguageId::new(id))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_relative_component_imports() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(src.join("components/Panel")).unwrap();
std::fs::write(src.join("components/ChildButton.vue"), "").unwrap();
std::fs::write(src.join("components/Panel/index.vue"), "").unwrap();
let app = src.join("App.vue");
assert_eq!(relative_module(&app, "./components/ChildButton.vue"), Some(src.join("components/ChildButton.vue")));
assert_eq!(relative_module(&app, "./components/ChildButton"), Some(src.join("components/ChildButton.vue")));
assert_eq!(relative_module(&app, "./components/Panel"), Some(src.join("components/Panel/index.vue")));
assert_eq!(
relative_module(&src.join("components/Panel/index.vue"), "../ChildButton.vue"),
Some(src.join("components/ChildButton.vue"))
);
assert_eq!(relative_module(&app, "@/components/ChildButton.vue"), None);
assert_eq!(relative_module(&app, "./components/Missing.vue"), None);
assert_eq!(src_alias(dir.path(), "@/components/ChildButton"), Some(src.join("components/ChildButton.vue")));
assert_eq!(src_alias(dir.path(), "~/components/ChildButton"), None);
}
}