From 87b463990c3fa57a0c5011e1ecc9364564c0a9ea Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:58:23 +0800 Subject: [PATCH] feat(nav): Follow links, file paths and CSS references with Go to Declaration Go to Declaration and Cmd-click said "Cannot find declaration" on HTML href/src, CSS url() and @import, Markdown links and CSS selectors: language servers expose paths as document links, not definitions, and none resolves class/id to selectors. - ide-lsp: textDocument/documentLink (lsp_document_links), resolving links that lack a target. - Editor: when the server has no definition, follow its document links, then URLs and quoted / Markdown / url() paths in any language. Server file links are checked to exist; root-relative paths also resolve against resource roots. Cmd-hover underlines the whole link. - ide-language: tree-sitter web references (class / id attributes, href="#id", var(--x)) and .class / #id / --var definitions from stylesheets and \n
\n"), + ]); + let idx = f.open(); + let found: Vec<(String, u32)> = idx + .symbols_named(".card", Some(SymbolKind::Class), 10) + .into_iter() + .map(|m| (m.rel_path, m.range.start.line)) + .collect(); + assert_eq!(found, vec![("web/a.css".into(), 0), ("web/b.scss".into(), 0), ("web/index.html".into(), 0)]); + assert_eq!(idx.symbols_named("--gap", None, 10).len(), 1); + assert!(idx.symbols_named(".card", Some(SymbolKind::Field), 10).is_empty()); +} diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 97b1b3520..67388a227 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -13,8 +13,8 @@ mod registry; mod syntax; pub use registry::LanguageRegistry; pub use syntax::{ - MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, - SyntaxHighlights, TreeError, + CSS_CLASS, CSS_ID, CSS_VARIABLE, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, Syntax, SyntaxCache, + SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind, }; /// Parsed form of `languages/.toml`. Backend-only; the UI gets [`LanguageInfo`]. diff --git a/crates/ide-language/src/syntax/mod.rs b/crates/ide-language/src/syntax/mod.rs index fed5514fe..ac4cb7daf 100644 --- a/crates/ide-language/src/syntax/mod.rs +++ b/crates/ide-language/src/syntax/mod.rs @@ -7,16 +7,18 @@ mod highlight; pub(crate) mod lines; mod outline; mod selection; +mod web; use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag}; pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats}; use grammars::{SPECS, Spec}; pub use highlight::{SyntaxHighlightKind, SyntaxHighlights}; -use ide_core::{LanguageId, OutlineItem, Range}; +use ide_core::{LanguageId, OutlineItem, Pos, Range}; use std::cell::RefCell; use std::collections::HashMap; use std::sync::OnceLock; use tree_sitter::{Language, Parser, Tree}; +pub use web::{CSS_CLASS, CSS_ID, CSS_VARIABLE, WebReference, WebReferenceKind}; /// Texts larger than this are not parsed (minified bundles, generated dumps). pub const MAX_PARSE_BYTES: usize = 16 * 1024 * 1024; @@ -117,6 +119,8 @@ impl Syntax { FP.get_or_init(|| { let mut h = blake3::Hasher::new(); h.update(env!("CARGO_PKG_VERSION").as_bytes()); + // Bump when web definitions (`.class`, `#id`, `--var` tags) change. + h.update(b"web-definitions-1"); for s in SPECS { h.update(s.name.as_bytes()); h.update(&Language::new(s.language).abi_version().to_le_bytes()); @@ -141,7 +145,8 @@ impl Syntax { /// Whether [`Syntax::tags`] can return anything for this language (data /// formats such as JSON only have an outline). Lets indexers skip parsing. pub fn has_symbols(&self, lang: &LanguageId) -> bool { - self.grammar(lang).and_then(Grammar::outline_query).is_some_and(|q| q.has_tags) + self.web_grammar(lang).is_some() + || self.grammar(lang).and_then(Grammar::outline_query).is_some_and(|q| q.has_tags) } pub fn outline(&self, lang: &LanguageId, text: &str) -> Vec { @@ -158,11 +163,56 @@ impl Syntax { } pub fn tags(&self, lang: &LanguageId, text: &str) -> Vec { - self.analyze(lang, text, |g, tree, text| { - let query = g.outline_query()?; - Some(outline::tags(g, query, tree, text)) - }) - .unwrap_or_default() + let mut tags = self + .analyze(lang, text, |g, tree, text| { + let query = g.outline_query()?; + Some(outline::tags(g, query, tree, text)) + }) + .unwrap_or_default(); + tags.extend(self.web_definitions(lang, text)); + tags + } + + /// The grammar web features parse `lang` with: its own for HTML and CSS, the + /// CSS grammar for SCSS / Less (selectors and custom properties parse alike), + /// HTML for Vue / Svelte single-file components. + fn web_grammar(&self, lang: &LanguageId) -> Option<&Grammar> { + let name = match lang.as_str() { + "scss" | "less" => "css", + "vue" | "svelte" => "html", + _ => self.grammar(lang).map(|g| g.spec.name)?, + }; + web::is_web_grammar(name).then(|| self.grammar(&LanguageId::new(name))).flatten() + } + + fn with_web_tree( + &self, + lang: &LanguageId, + text: &str, + f: impl FnOnce(&Grammar, Option<&Grammar>, &Tree) -> R, + ) -> Option { + let g = self.web_grammar(lang)?; + if text.len() > MAX_PARSE_BYTES { + return None; + } + let tree = parse(&g.language, text, None)?; + Some(f(g, self.grammar(&LanguageId::new("css")), &tree)) + } + + /// CSS class (`.card`), id (`#main`) and custom property (`--accent`) + /// definitions of a stylesheet or of the `"; + assert_eq!(web_defs("html", html), vec![(".card".into(), CSS_CLASS, 2, 3), ("--gap".into(), CSS_VARIABLE, 2, 10)]); + assert!(syntax().tags(&lang("css"), css).iter().any(|t| t.name == ".a")); + assert!(syntax().has_symbols(&lang("css")) && syntax().has_symbols(&lang("vue"))); +} + +#[test] +fn web_references_in_attributes_and_stylesheets() { + use WebReferenceKind::*; + assert_eq!(web_ref("html", r##"
"##), Some((Class, "big".into(), 17, 20))); + assert_eq!(web_ref("html", r##"
"##), Some((Class, "card".into(), 12, 16))); + assert_eq!(web_ref("html", r##"
"##), Some((Id, "main".into(), 9, 13))); + assert_eq!(web_ref("html", r##""##), Some((Anchor, "top".into(), 9, 13))); + assert_eq!(web_ref("html", r##""##), None); + assert_eq!( + web_ref("html", r##"

"##), + Some((CssVariable, "--accent".into(), 21, 29)) + ); + assert_eq!(web_ref("html", "").map(|r| r.1), Some("--x".into())); + assert_eq!(web_ref("css", ".a { color: var(--a|ccent, red) }"), Some((CssVariable, "--accent".into(), 16, 24))); + assert_eq!(web_ref("css", ".a { color: re|d }"), None); + assert_eq!(web_ref("vue", r##""##).map(|r| r.0), Some(Class)); +} + +#[test] +fn web_anchors_find_ids_and_names() { + let html = "

x

\n
\n

"; + let found: Vec<(u32, u32)> = + syntax().web_anchors(&lang("html"), html, "top").into_iter().map(|r| (r.start.line, r.start.col)).collect(); + assert_eq!(found, vec![(0, 8), (1, 9)]); +} diff --git a/crates/ide-language/src/syntax/web.rs b/crates/ide-language/src/syntax/web.rs new file mode 100644 index 000000000..b221ae721 --- /dev/null +++ b/crates/ide-language/src/syntax/web.rs @@ -0,0 +1,252 @@ +//! Web references that no language server resolves: HTML `class` / `id` +//! attributes to the CSS selectors that style them, `href="#id"` to the element +//! with that id, and `var(--x)` to the custom property's declarations. +//! +//! Definitions (`.card`, `#main`, `--accent`) come from stylesheets and the +//! `