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 <style> elements, indexed as symbols. web_goto looks them up by exact name (ProjectIndex::symbols_named), using the open document's unsaved text.
- Detect public/ of package.json modules as a resource root (Vite, Next, CRA serve it at /).
Цей коміт міститься в:
l0ng-ai committed 2026-10-24 22:58:23 +08:00
1 джерело 046fd37064
коміт 87b463990c
20 files changed
+1060 -13

No files matched your search

+5
Переглянути файл
@@ -121,6 +121,11 @@ impl ProjectIndex {
matching::find_symbols(&d, &matching::Recents { paths: &recent }, query, filter, limit)
}
/// Symbols named exactly `name` (of `kind`, if given): `.card` selectors for `class="card"`.
pub fn symbols_named(&self, name: &str, kind: Option<SymbolKind>, limit: usize) -> Vec<SymbolMatch> {
matching::symbols_named(&self.shared.data.read(), name, kind, limit)
}
/// Incrementally re-indexes changed paths (called from the vfs watcher).
/// Cheap: the work is queued for the indexing thread.
pub fn update(&self, changed: &[PathBuf], removed: &[PathBuf]) {
+28
Переглянути файл
@@ -7,6 +7,7 @@
use crate::data::{FileEntry, IndexData, char_mask};
use crate::{FileMatch, SymbolFilter, SymbolMatch};
use ide_core::SymbolKind;
use nucleo_matcher::{Config, Matcher, Utf32Str, Utf32String};
use rayon::prelude::*;
use std::cell::RefCell;
@@ -419,6 +420,33 @@ pub(crate) fn find_symbols(
.collect()
}
/// Symbols named exactly `name` (and of `kind`, when given), by path then position.
pub(crate) fn symbols_named(data: &IndexData, name: &str, kind: Option<SymbolKind>, limit: usize) -> Vec<SymbolMatch> {
let mask = char_mask(name);
let mut out: Vec<SymbolMatch> = data
.files
.iter()
.flatten()
.flat_map(|e| {
e.symbols.iter().filter(|s| s.mask == mask && &*s.name == name && kind.is_none_or(|k| s.kind == k)).map(
move |s| SymbolMatch {
name: s.name.to_string(),
kind: s.kind,
container: s.container.as_deref().map(str::to_owned),
path: e.path.clone().into(),
rel_path: e.rel.to_string(),
range: s.range,
score: 0,
indices: Vec::new(),
},
)
})
.collect();
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path).then_with(|| a.range.cmp(&b.range)));
out.truncate(limit);
out
}
#[cfg(test)]
mod tests {
use super::*;
+18
Переглянути файл
@@ -381,3 +381,21 @@ fn synthetic_index_answers_queries() {
assert!(!s.find_symbols("PaySe", SymbolFilter::All, 20).is_empty());
assert!(s.find_symbols("PaySe", SymbolFilter::Types, 20).iter().all(|m| m.kind == SymbolKind::Class));
}
#[test]
fn css_selectors_are_found_by_exact_name() {
let f = Fixture::new(&[
("web/a.css", ".card { --gap: 1px }\n.cardigan {}\n"),
("web/b.scss", ".list { .card { } }\n"),
("web/index.html", "<style>.card{}</style>\n<div class=\"card\"></div>\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());
}
+2 -2
Переглянути файл
@@ -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/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
+57 -7
Переглянути файл
@@ -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<OutlineItem> {
@@ -158,11 +163,56 @@ impl Syntax {
}
pub fn tags(&self, lang: &LanguageId, text: &str) -> Vec<SymbolTag> {
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<R>(
&self,
lang: &LanguageId,
text: &str,
f: impl FnOnce(&Grammar, Option<&Grammar>, &Tree) -> R,
) -> Option<R> {
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 `<style>` elements of a document.
pub fn web_definitions(&self, lang: &LanguageId, text: &str) -> Vec<SymbolTag> {
self.with_web_tree(lang, text, |g, css, tree| web::definitions(g, css, tree, text)).unwrap_or_default()
}
/// The `class` / `id` / `href="#…"` / `var(--…)` reference at `pos`.
pub fn web_reference_at(&self, lang: &LanguageId, text: &str, pos: Pos) -> Option<WebReference> {
self.with_web_tree(lang, text, |g, css, tree| web::reference_at(g, css, tree, text, pos)).flatten()
}
/// Value ranges of the `id="name"` / `name="name"` attributes in an HTML document.
pub fn web_anchors(&self, lang: &LanguageId, text: &str, name: &str) -> Vec<Range> {
self.with_web_tree(lang, text, |_, _, tree| web::anchors(tree, text, name)).unwrap_or_default()
}
/// Highlighting from the grammar's own query; `None` without one.
+69
Переглянути файл
@@ -1085,3 +1085,72 @@ fn large_input_is_not_parsed() {
let big = "a".repeat(MAX_PARSE_BYTES + 1);
assert!(syntax().outline(&lang("rust"), &big).is_empty());
}
// --- web references -------------------------------------------------------------
fn web_defs(id: &str, src: &str) -> Vec<(String, SymbolKind, u32, u32)> {
syntax()
.web_definitions(&lang(id), src)
.into_iter()
.map(|t| (t.name, t.kind, t.range.start.line, t.range.start.col))
.collect()
}
fn web_ref(id: &str, src: &str) -> Option<(WebReferenceKind, String, u32, u32)> {
let at = src.find('|').expect("caret marker");
let text = src.replace('|', "");
let line = text[..at].matches('\n').count() as u32;
let col = (at - text[..at].rfind('\n').map_or(0, |i| i + 1)) as u32;
syntax()
.web_reference_at(&lang(id), &text, Pos::new(line, col))
.map(|r| (r.kind, r.name, r.range.start.col, r.range.end.col))
}
#[test]
fn web_definitions_of_stylesheets_and_style_elements() {
let css = ".a.b, div#main > .c:hover { color: var(--x); --y: 1px; }\n@media (x) { .d {} }\n";
assert_eq!(
web_defs("css", css),
vec![
(".a".into(), CSS_CLASS, 0, 1),
(".b".into(), CSS_CLASS, 0, 3),
("#main".into(), CSS_ID, 0, 10),
(".c".into(), CSS_CLASS, 0, 18),
("--y".into(), CSS_VARIABLE, 0, 45),
(".d".into(), CSS_CLASS, 1, 14),
]
);
// Pseudo-classes (`:hover`) are not classes; nested SCSS rules parse with the CSS grammar.
assert!(!web_defs("css", css).iter().any(|d| d.0 == ".hover"));
assert!(web_defs("scss", ".card { &:hover { } .title { } }").iter().any(|d| d.0 == ".title"));
let html = "<p class=\"x\">\n<style>\n .card { --gap: 2px }\n</style>";
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##"<div class="card bi|g">"##), Some((Class, "big".into(), 17, 20)));
assert_eq!(web_ref("html", r##"<div class="|card big">"##), Some((Class, "card".into(), 12, 16)));
assert_eq!(web_ref("html", r##"<div id="ma|in">"##), Some((Id, "main".into(), 9, 13)));
assert_eq!(web_ref("html", r##"<a href="#to|p">"##), Some((Anchor, "top".into(), 9, 13)));
assert_eq!(web_ref("html", r##"<a href="page.ht|ml">"##), None);
assert_eq!(
web_ref("html", r##"<p style="color: var(--ac|cent)">"##),
Some((CssVariable, "--accent".into(), 21, 29))
);
assert_eq!(web_ref("html", "<style>\n.a { color: var(--x|) }\n</style>").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##"<template><div class="ti|tle"/></template>"##).map(|r| r.0), Some(Class));
}
#[test]
fn web_anchors_find_ids_and_names() {
let html = "<h2 id=\"top\">x</h2>\n<a name=\"top\"></a>\n<p class=\"top\">";
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)]);
}
+252
Переглянути файл
@@ -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
//! `<style>` elements of HTML documents and feed the symbol index (see
//! [`super::Syntax::tags`]), so a lookup is an exact index query.
use super::lines::{LineIndex, utf16_to_byte};
use super::{Grammar, parse};
use crate::SymbolTag;
use ide_core::{Pos, Range, SymbolKind};
use serde::{Deserialize, Serialize};
use specta::Type;
use tree_sitter::{Node, Tree};
/// Index symbol kinds of web definitions; names carry the CSS sigil.
pub const CSS_CLASS: SymbolKind = SymbolKind::Class;
pub const CSS_ID: SymbolKind = SymbolKind::Field;
pub const CSS_VARIABLE: SymbolKind = SymbolKind::Variable;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum WebReferenceKind {
/// A class in a `class` / `className` attribute: `.name` selectors.
Class,
/// An `id` attribute value: `#name` selectors.
Id,
/// `href="#name"`: the element with that id in the same document.
Anchor,
/// `var(--name)`: the custom property's declarations.
CssVariable,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebReference {
pub kind: WebReferenceKind,
/// Without sigil: `card`, `main`, `--accent`.
pub name: String,
pub range: Range,
}
impl WebReference {
/// The index symbol a reference resolves to: `.card`, `#main`, `--accent`.
pub fn symbol(&self) -> Option<(String, SymbolKind)> {
match self.kind {
WebReferenceKind::Class => Some((format!(".{}", self.name), CSS_CLASS)),
WebReferenceKind::Id => Some((format!("#{}", self.name), CSS_ID)),
WebReferenceKind::CssVariable => Some((self.name.clone(), CSS_VARIABLE)),
WebReferenceKind::Anchor => None,
}
}
}
/// Grammars whose documents carry web definitions and references.
pub(crate) fn is_web_grammar(name: &str) -> bool {
matches!(name, "css" | "html")
}
/// Stylesheet text embedded in a document: `(byte offset, text)`.
fn css_regions<'t>(grammar: &str, tree: &Tree, text: &'t str) -> Vec<(usize, &'t str)> {
match grammar {
"css" => vec![(0, text)],
"html" => {
let mut out = Vec::new();
walk(tree.root_node(), &mut |n| {
if n.kind() == "style_element" {
let mut c = n.walk();
for raw in n.children(&mut c).filter(|c| c.kind() == "raw_text") {
out.push((raw.start_byte(), &text[raw.start_byte()..raw.end_byte()]));
}
return false;
}
true
});
out
}
_ => Vec::new(),
}
}
/// 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);
}
}
fn text_of<'t>(n: Node, text: &'t str) -> &'t str {
text.get(n.start_byte()..n.end_byte()).unwrap_or_default()
}
/// `.name`, `#name` and `--name` definitions in `text` (a stylesheet, or a document with `<style>`).
pub(crate) fn definitions(g: &Grammar, css: Option<&Grammar>, tree: &Tree, text: &str) -> Vec<SymbolTag> {
let idx = LineIndex::new(text);
let mut out = Vec::new();
for (base, css_text) in css_regions(g.spec.name, tree, text) {
let parsed;
let css_tree = if base == 0 && g.spec.name == "css" {
tree
} else {
let Some(css) = css else { continue };
let Some(t) = parse(&css.language, css_text, None) else { continue };
parsed = t;
&parsed
};
walk(css_tree.root_node(), &mut |n| {
let def = match n.kind() {
"class_name" if n.parent().is_some_and(|p| p.kind() == "class_selector") => {
Some((format!(".{}", text_of(n, css_text)), CSS_CLASS))
}
"id_name" => Some((format!("#{}", text_of(n, css_text)), CSS_ID)),
"property_name" if text_of(n, css_text).starts_with("--") => {
Some((text_of(n, css_text).to_owned(), CSS_VARIABLE))
}
_ => None,
};
if let Some((name, kind)) = def {
let range = idx.range(base + n.start_byte(), base + n.end_byte());
out.push(SymbolTag { name, kind, container: None, range });
}
true
});
}
out
}
fn byte_at(text: &str, pos: Pos) -> Option<usize> {
let mut start = 0;
for _ in 0..pos.line {
start += text[start..].find('\n')? + 1;
}
let line_end = text[start..].find('\n').map_or(text.len(), |i| start + i);
Some(start + utf16_to_byte(&text[start..line_end], pos.col as usize))
}
/// The whitespace-separated token of `value` (starting at byte `base`) around byte `at`.
fn token_at(value: &str, base: usize, at: usize) -> Option<(usize, &str)> {
let rel = at.checked_sub(base)?;
let start = value[..rel.min(value.len())].rfind(char::is_whitespace).map_or(0, |i| i + 1);
let end = value[rel.min(value.len())..].find(char::is_whitespace).map_or(value.len(), |i| rel + i);
(start < end).then(|| (base + start, &value[start..end]))
}
/// `var(--name)` around byte `at` of a stylesheet tree.
fn css_variable_at(tree: &Tree, css_text: &str, at: usize) -> Option<(usize, usize, String)> {
let mut n = tree.root_node().descendant_for_byte_range(at, at)?;
if n.kind() != "plain_value" {
// The caret right after `--x` in `var(--x)`.
n = tree.root_node().descendant_for_byte_range(at.saturating_sub(1), at.saturating_sub(1))?;
}
let name = text_of(n, css_text);
let call = n.parent()?.parent()?;
let is_var = call.kind() == "call_expression"
&& call
.child_by_field_name("function_name")
.or_else(|| call.child(0))
.is_some_and(|f| text_of(f, css_text) == "var");
(n.kind() == "plain_value" && name.starts_with("--") && is_var)
.then(|| (n.start_byte(), n.end_byte(), name.to_owned()))
}
/// The web reference at `pos` of a document parsed as `tree`.
pub(crate) fn reference_at(
g: &Grammar,
css: Option<&Grammar>,
tree: &Tree,
text: &str,
pos: Pos,
) -> Option<WebReference> {
let at = byte_at(text, pos)?;
let idx = LineIndex::new(text);
let make = |kind, from: usize, to: usize, name: String| WebReference { kind, name, range: idx.range(from, to) };
// Stylesheet regions: `var(--x)`.
for (base, css_text) in css_regions(g.spec.name, tree, text) {
if at < base || at > base + css_text.len() {
continue;
}
let parsed;
let css_tree = if g.spec.name == "css" {
tree
} else {
parsed = parse(&css?.language, css_text, None)?;
&parsed
};
let (from, to, name) = css_variable_at(css_tree, css_text, at - base)?;
return Some(make(WebReferenceKind::CssVariable, base + from, base + to, name));
}
if g.spec.name != "html" {
return None;
}
// HTML attribute values.
let node = tree.root_node().descendant_for_byte_range(at, at)?;
let value = std::iter::successors(Some(node), |n| n.parent()).find(|n| n.kind() == "attribute_value")?;
let attribute = std::iter::successors(value.parent(), |n| n.parent()).find(|n| n.kind() == "attribute")?;
let attr_name = attribute.child(0).map(|n| text_of(n, text).to_ascii_lowercase())?;
let (vs, vtext) = (value.start_byte(), text_of(value, text));
match attr_name.as_str() {
"class" | "classname" | "class:list" => {
let (from, token) = token_at(vtext, vs, at)?;
Some(make(WebReferenceKind::Class, from, from + token.len(), token.to_owned()))
}
"id" => {
let (from, token) = token_at(vtext, vs, at)?;
Some(make(WebReferenceKind::Id, from, from + token.len(), token.to_owned()))
}
"href" | "xlink:href" if vtext.len() > 1 && vtext.starts_with('#') => {
Some(make(WebReferenceKind::Anchor, vs, vs + vtext.len(), vtext[1..].to_owned()))
}
"style" => {
// An inline declaration list: parse it as the block of a rule.
let wrapped = format!("x{{{vtext}}}");
let t = parse(&css?.language, &wrapped, None)?;
let (from, to, name) = css_variable_at(&t, &wrapped, at - vs + 2)?;
Some(make(WebReferenceKind::CssVariable, vs + from - 2, vs + to - 2, name))
}
_ => None,
}
}
/// Elements whose `id` (or `<a name>`) is `name`: the targets of `href="#name"`.
pub(crate) fn anchors(tree: &Tree, text: &str, name: &str) -> Vec<Range> {
let idx = LineIndex::new(text);
let mut out = Vec::new();
walk(tree.root_node(), &mut |n| {
if n.kind() == "attribute" {
let attr = n.child(0).map(|a| text_of(a, text).to_ascii_lowercase());
if matches!(attr.as_deref(), Some("id" | "name")) {
let mut found = None;
walk(n, &mut |v| {
if v.kind() == "attribute_value" && text_of(v, text) == name {
found = Some(v);
}
found.is_none()
});
if let Some(v) = found {
out.push(idx.range(v.start_byte(), v.end_byte()));
}
}
return false;
}
true
});
out
}
+2
Переглянути файл
@@ -121,6 +121,7 @@ pub fn client_capabilities() -> Value {
"honorsChangeAnnotations": false
},
"codeLens": { "dynamicRegistration": false },
"documentLink": { "dynamicRegistration": false, "tooltipSupport": true },
"formatting": { "dynamicRegistration": false },
"rangeFormatting": { "dynamicRegistration": false },
"rename": {
@@ -205,6 +206,7 @@ pub fn capability_key(method: &str) -> Option<&'static str> {
"textDocument/inlayHint" => "inlayHintProvider",
"textDocument/semanticTokens" | "textDocument/semanticTokens/full" => "semanticTokensProvider",
"textDocument/codeLens" => "codeLensProvider",
"textDocument/documentLink" => "documentLinkProvider",
"textDocument/prepareCallHierarchy" => "callHierarchyProvider",
"textDocument/prepareTypeHierarchy" => "typeHierarchyProvider",
_ => return None,
+42
Переглянути файл
@@ -783,6 +783,27 @@ pub fn code_lens(conv: &Conv, path: &Path, l: lsp::CodeLens) -> CodeLens {
CodeLens { range: conv.range(path, l.range), command: l.command.map(command) }
}
pub fn document_link(conv: &Conv, path: &Path, l: lsp::DocumentLink) -> Option<DocumentLink> {
let target = l.target?;
let target = target.as_str();
let (file, url) = match uri_to_path(target) {
Some(p) => (Some(p), None),
None => (None, Some(target.to_owned())),
};
let fragment = file
.as_ref()
.and_then(|_| target.split_once('#'))
.map(|(_, f)| percent_encoding::percent_decode_str(f).decode_utf8_lossy().into_owned())
.filter(|f| !f.is_empty());
Some(DocumentLink {
range: conv.range(path, l.range),
path: file.map(Into::into),
fragment,
url,
tooltip: l.tooltip,
})
}
pub fn call_item(conv: &Conv, item: &lsp::CallHierarchyItem, from_ranges: Vec<Range>) -> Option<HierarchyItem> {
let (path, uri) = uri_target(item.uri.as_str());
Some(HierarchyItem {
@@ -1112,4 +1133,25 @@ mod tests {
assert_eq!(h.kind, InlayKind::Type);
assert!(h.padding_left && !h.padding_right);
}
#[cfg(unix)]
#[test]
fn document_links_split_file_targets_and_urls() {
let conv = Conv::utf16();
let link = |target: Option<&str>| {
serde_json::from_value::<lsp::DocumentLink>(json!({
"range": { "start": { "line": 0, "character": 6 }, "end": { "line": 0, "character": 15 } },
"target": target,
}))
.unwrap()
};
let here = Path::new("/p/index.html");
let file = document_link(&conv, here, link(Some("file:///p/a%20b.md#L12"))).unwrap();
assert_eq!(file.path.as_deref(), Some(Path::new("/p/a b.md")));
assert_eq!(file.fragment.as_deref(), Some("L12"));
assert_eq!(file.range, Range::new(p(0, 6), p(0, 15)));
let web = document_link(&conv, here, link(Some("https://example.com/#top"))).unwrap();
assert_eq!((web.path, web.url.as_deref(), web.fragment), (None, Some("https://example.com/#top"), None));
assert!(document_link(&conv, here, link(None)).is_none());
}
}
+46
Переглянути файл
@@ -1422,6 +1422,52 @@ impl LspManager {
out.finish(Vec::new())
}
/// The document's links; ones without a target are resolved when the server can.
pub async fn document_links(&self, doc: DocId) -> IdeResult<Vec<DocumentLink>> {
let (snap, targets) = self.doc_targets(doc, "document links", |c| c.has("documentLinkProvider")).await?;
let requests = targets.iter().map(|t| {
let params = json!({ "textDocument": snap.ident() });
async move {
let links = self
.call::<_, Option<Vec<lsp::DocumentLink>>>(
t,
"textDocument/documentLink",
params,
None,
DEFAULT_TIMEOUT,
)
.await?
.unwrap_or_default();
if !t.caps().has_sub("documentLinkProvider", "resolveProvider") {
return Ok::<_, IdeError>((t, links));
}
let resolved = join_all(links.into_iter().map(|l| async move {
if l.target.is_some() {
return l;
}
let original = l.clone();
self.call::<_, lsp::DocumentLink>(t, "documentLink/resolve", l, None, DEFAULT_TIMEOUT)
.await
.unwrap_or(original)
}))
.await;
Ok((t, resolved))
}
});
let mut out = Outcome::new();
for r in join_all(requests).await {
match r {
Ok((t, links)) => {
let conv = self.conv(t.enc());
let acc = out.value.get_or_insert_with(Vec::new);
acc.extend(links.into_iter().filter_map(|l| convert::document_link(&conv, &snap.path, l)));
}
Err(e) => out.fail(e),
}
}
out.finish(Vec::new())
}
pub async fn call_hierarchy(
&self,
doc: DocId,
+15
Переглянути файл
@@ -214,6 +214,21 @@ pub struct CodeLens {
pub command: Option<Command>,
}
/// A link in a document (`textDocument/documentLink`): an HTML `href`, a
/// Markdown link, an `#include`, a JSON `$schema`…
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DocumentLink {
pub range: Range,
/// A `file:` target.
pub path: Option<ide_core::IpcPath>,
/// The `#fragment` of a `file:` target (`L12`, `L12,5`, a heading or element id).
pub fragment: Option<String>,
/// Any other target (`https:`, …).
pub url: Option<String>,
pub tooltip: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum HierarchyDirection {
+4
Переглянути файл
@@ -192,6 +192,8 @@ fn module_roots(root: &Path, rel: &str, files: &[String], out: &mut Vec<SourceFo
// A JVM build's `src` holds source sets, not JavaScript.
if !has("pom.xml") && !GRADLE_FILES.iter().any(|f| has(f)) {
add("src", RootKind::Source, "package.json");
// Served at `/` by Vite, Next and Create React App: `href="/logo.svg"` resolves here.
add("public", RootKind::Resources, "package.json");
for t in ["test", "tests", "__tests__"] {
add(t, RootKind::Test, "package.json");
}
@@ -330,6 +332,7 @@ mod tests {
"flat/tests/",
"web/package.json",
"web/src/",
"web/public/",
"web/__tests__/",
"web/node_modules/x/package.json",
"web/dist/",
@@ -351,6 +354,7 @@ mod tests {
"web/__tests__=Test@web/package.json",
"web/dist=Excluded@web/package.json",
"web/node_modules=Excluded@web/package.json",
"web/public=Resources@web/package.json",
"web/src=Source@web/package.json",
]
);
+11
Переглянути файл
@@ -243,6 +243,17 @@ pub async fn lsp_code_lens(window: Window, state: State<'_, AppState>, doc: DocI
project!(window, state).lsp.code_lens(doc).await
}
/// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers.
#[tauri::command]
#[specta::specta]
pub async fn lsp_document_links(
window: Window,
state: State<'_, AppState>,
doc: DocId,
) -> IdeResult<Vec<DocumentLink>> {
project!(window, state).lsp.document_links(doc).await
}
#[tauri::command]
#[specta::specta]
pub async fn lsp_call_hierarchy(
+2
Переглянути файл
@@ -103,6 +103,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
nav::find_symbols,
nav::mark_recent,
nav::recent_files,
nav::web_goto,
search::search_start,
search::replace_in_files,
search::replace_preview,
@@ -125,6 +126,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
lsp::lsp_inlay_hints,
lsp::lsp_semantic_tokens,
lsp::lsp_code_lens,
lsp::lsp_document_links,
lsp::lsp_call_hierarchy,
lsp::lsp_type_hierarchy,
lsp::lsp_servers,
+56 -1
Переглянути файл
@@ -1,6 +1,10 @@
use super::doc::blocking;
use crate::state::AppState;
use ide_core::IdeResult;
use ide_core::{DocId, IdeResult, Location, Pos};
use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_language::WebReference;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::PathBuf;
use tauri::{State, Window};
@@ -41,3 +45,54 @@ pub fn mark_recent(window: Window, state: State<'_, AppState>, path: PathBuf) ->
pub fn recent_files(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ide_core::IpcPath>> {
Ok(state.project(window.label())?.index.recent_files().into_iter().map(Into::into).collect())
}
/// A web reference no language server resolves and where it points: `class="card"` →
/// `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` →
/// `--x:` declarations.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebGoto {
pub reference: WebReference,
pub targets: Vec<Location>,
}
const MAX_WEB_TARGETS: usize = 200;
/// The web reference at `pos` of `doc` and its targets; `None` when there is no reference.
#[tauri::command]
#[specta::specta]
pub async fn web_goto(window: Window, state: State<'_, AppState>, doc: DocId, pos: Pos) -> IdeResult<Option<WebGoto>> {
let project = state.project(window.label())?;
let syntax = state.syntax();
blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large {
return Ok(None);
}
let Some(reference) = syntax.web_reference_at(&snap.language, &snap.text, pos) else { return Ok(None) };
let here = |range| Location { path: snap.path.clone(), range, uri: None };
let targets = match reference.symbol() {
None => syntax.web_anchors(&snap.language, &snap.text, &reference.name).into_iter().map(here).collect(),
Some((name, kind)) => {
// The open document from its (possibly unsaved) text, other files from the index.
let mut targets: Vec<Location> = syntax
.web_definitions(&snap.language, &snap.text)
.into_iter()
.filter(|t| t.name == name && t.kind == kind)
.map(|t| here(t.range))
.collect();
targets.extend(
project
.index
.symbols_named(&name, Some(kind), MAX_WEB_TARGETS)
.into_iter()
.filter(|m| *m.path != *snap.path)
.map(|m| Location { path: m.path, range: m.range, uri: None }),
);
targets
}
};
Ok(Some(WebGoto { reference, targets }))
})
.await
}
+49
Переглянути файл
@@ -186,6 +186,11 @@ export const commands = {
markRecent: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("mark_recent", { path })),
/** Recently opened files, most recent first (⌘E). */
recentFiles: () => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("recent_files")),
/** The web reference at `pos` of `doc` and its targets; `None` when there is no reference. */
webGoto: (doc: DocId, pos: Pos) => typedError<{
reference: WebReference,
targets: Location[],
} | null, IdeError>(__TAURI_INVOKE("web_goto", { doc, pos })),
/**
* Find in Files. Results stream through `on_event`; ends with `Done`.
* Cancel with `cancel_request(request)`.
@@ -248,6 +253,8 @@ export const commands = {
tokenModifiers: string[],
} | null, IdeError>(__TAURI_INVOKE("lsp_semantic_tokens", { doc })),
lspCodeLens: (doc: DocId) => typedError<CodeLens[], IdeError>(__TAURI_INVOKE("lsp_code_lens", { doc })),
/** Links in a document (`href`s, Markdown links, `#include`s…) from its language servers. */
lspDocumentLinks: (doc: DocId) => typedError<DocumentLink[], IdeError>(__TAURI_INVOKE("lsp_document_links", { doc })),
lspCallHierarchy: (doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError<HierarchyItem[], IdeError>(__TAURI_INVOKE("lsp_call_hierarchy", { doc, pos, direction })),
lspTypeHierarchy: (doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError<HierarchyItem[], IdeError>(__TAURI_INVOKE("lsp_type_hierarchy", { doc, pos, direction })),
lspServers: () => typedError<ServerInfo[], IdeError>(__TAURI_INVOKE("lsp_servers")),
@@ -1575,6 +1582,21 @@ export type DocumentInfo = {
uri?: string | null,
};
/**
* A link in a document (`textDocument/documentLink`): an HTML `href`, a
* Markdown link, an `#include`, a JSON `$schema`…
*/
export type DocumentLink = {
range: Range,
/** A `file:` target. */
path: IpcPath | null,
/** The `#fragment` of a `file:` target (`L12`, `L12,5`, a heading or element id). */
fragment: string | null,
/** Any other target (`https:`, …). */
url: string | null,
tooltip: string | null,
};
export type DuplicateGroup = {
tokens: number,
/** Lines of the first fragment. */
@@ -4028,6 +4050,33 @@ export type VirtualDocument = {
decompiled: boolean,
};
/**
* A web reference no language server resolves and where it points: `class="card"` →
* `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` →
* `--x:` declarations.
*/
export type WebGoto = {
reference: WebReference,
targets: Location[],
};
export type WebReference = {
kind: WebReferenceKind,
/** Without sigil: `card`, `main`, `--accent`. */
name: string,
range: Range,
};
export type WebReferenceKind =
/** A class in a `class` / `className` attribute: `.name` selectors. */
"class" |
/** An `id` attribute value: `#name` selectors. */
"id" |
/** `href="#name"`: the element with that id in the same document. */
"anchor" |
/** `var(--name)`: the custom property's declarations. */
"cssVariable";
/** Edits to many files, applied atomically from the user's point of view. */
export type WorkspaceEdit = {
files: FileEdit[],
+73
Переглянути файл
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { Text } from "@codemirror/state";
import { fragmentOffset, linkReference, looksLikePath, normalizePath, pathCandidates, textReference } from "./links";
const at = (src: string, marker = "|") => {
const offset = src.indexOf(marker);
return { text: Text.of(src.replace(marker, "").split("\n")), offset };
};
describe("text references", () => {
it("finds URLs, trimming trailing punctuation", () => {
const { text, offset } = at("// see https://exa|mple.com/a?b=1.");
expect(textReference(text, offset)).toMatchObject({ kind: "url", url: "https://example.com/a?b=1" });
});
it("finds quoted paths in any language", () => {
const { text, offset } = at('df = open("data/x|.csv")');
expect(textReference(text, offset)).toMatchObject({ kind: "path", value: "data/x.csv" });
expect(textReference(...(Object.values(at("<link href='sty|le.css'>")) as [Text, number]))).toMatchObject({
value: "style.css",
});
});
it("finds Markdown link targets and unquoted CSS urls", () => {
let { text, offset } = at("See [theme](css/the|me.css#L1) now");
expect(textReference(text, offset)).toMatchObject({ kind: "path", value: "css/theme.css#L1" });
({ text, offset } = at(".a { background: url(img/lo|go.svg); }"));
expect(textReference(text, offset)).toMatchObject({ kind: "path", value: "img/logo.svg" });
});
it("ignores prose and identifiers", () => {
expect(looksLikePath("hello world.txt")).toBe(false);
expect(looksLikePath("utf-8")).toBe(false);
expect(looksLikePath("main")).toBe(false);
expect(looksLikePath("http://x")).toBe(false);
expect(looksLikePath("../a/b")).toBe(true);
expect(looksLikePath("logo.svg#icon")).toBe(true);
const { text, offset } = at('log("do|ne")');
expect(textReference(text, offset)).toBeNull();
});
});
describe("path resolution", () => {
it("resolves against the file's directory, then the project root", () => {
expect(pathCandidates("../img/a%20b.png?v=2", "/p/web/x/index.html", "/p")).toEqual(["/p/web/img/a b.png"]);
expect(pathCandidates("img/a.png", "/p/web/index.html", "/p")).toEqual(["/p/web/img/a.png", "/p/img/a.png"]);
expect(pathCandidates("/splash.css", "/p/index.html", "/p", ["/p/public"])).toEqual([
"/p/splash.css",
"/p/public/splash.css",
"/splash.css",
]);
expect(normalizePath("/a/./b/../c")).toBe("/a/c");
});
});
describe("links and fragments", () => {
it("picks the server link around the offset", () => {
const text = Text.of(['<a href="b.html#top">']);
const range = { start: { line: 0, col: 9 }, end: { line: 0, col: 19 } };
const link = { range, path: "/p/b.html", fragment: "top", url: null, tooltip: null };
expect(linkReference(text, [link], 12)).toMatchObject({ kind: "file", path: "/p/b.html", fragment: "top" });
expect(linkReference(text, [link], 2)).toBeNull();
});
it("locates line, element id and Markdown heading fragments", () => {
expect(fragmentOffset("a\nbc\nd", "/x.ts", "L2")).toBe(2);
expect(fragmentOffset("a\nbc\nd", "/x.ts", "L2,2")).toBe(3);
const html = '<p>\n<div class="x" id="main">';
expect(fragmentOffset(html, "/i.html", "main")).toBe(html.indexOf("id="));
expect(fragmentOffset("# Intro\n\n## Getting Started!\n", "/r.md", "getting-started")).toBe(9);
expect(fragmentOffset("x", "/i.html", "nope")).toBeNull();
});
});
+238
Переглянути файл
@@ -0,0 +1,238 @@
/**
* References that Go to Declaration and ⌘-click follow when the language
* server has no definition there, in every language:
* - document links from the server (`textDocument/documentLink`: HTML `href`,
* CSS `url()`, Markdown links, `#include`, JSON `$schema`…);
* - without one, URLs in the text and quoted strings naming an existing file
* (IDEA's file references: `open("data/x.csv")`, `include_str!("a.txt")`).
*/
import type { Text } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { whenSynced, type EditorDocContext } from "@/editor/api";
import { call, commands, isTauri, type DocumentLink } from "@/platform/ipc";
import { openExternalUrl } from "@/platform/openUrl";
import { useProject } from "@/platform/project";
import { currentRoots } from "@/platform/projectRoots";
import { editorService } from "@/workbench/contributions";
import { lspRequest } from "./lsp";
import { rangeToOffsets } from "./positions";
export type Reference =
| { kind: "file"; from: number; to: number; path: string; fragment?: string | null }
| { kind: "url"; from: number; to: number; url: string }
/** A quoted path that still has to be resolved against the file system. */
| { kind: "path"; from: number; to: number; value: string }
/** A link whose file does not exist (reported, never opened). */
| { kind: "missing"; from: number; to: number; value: string };
// --- document links ----------------------------------------------------------
const linkCache = new WeakMap<Text, Promise<DocumentLink[]>>();
const fetchedLinks = new WeakMap<Text, DocumentLink[]>();
/** The server's links of the document as it is now (cached per document version). */
export function documentLinks(view: EditorView, doc: EditorDocContext): Promise<DocumentLink[]> {
const text = view.state.doc;
let links = linkCache.get(text);
if (!links) {
links = whenSynced(view)
.then(() => lspRequest(() => commands.lspDocumentLinks(doc.doc)))
.catch(() => [])
.then((list) => {
fetchedLinks.set(text, list);
return list;
});
linkCache.set(text, links);
}
return links;
}
/** Links already fetched for the current text, if any (the ⌘-hover underline is synchronous). */
export function knownLinks(view: EditorView): DocumentLink[] | undefined {
return fetchedLinks.get(view.state.doc);
}
export function linkReference(text: Text, links: readonly DocumentLink[], offset: number): Reference | null {
for (const l of links) {
const { from, to } = rangeToOffsets(text, l.range);
if (offset < from || offset > to) continue;
if (l.path) return { kind: "file", from, to, path: l.path, fragment: l.fragment };
if (l.url) return { kind: "url", from, to, url: l.url };
}
return null;
}
// --- references found in the text -------------------------------------------
const URL_RE = /\b(?:https?:\/\/|mailto:)[^\s"'`<>()[\]{}]+[^\s"'`<>()[\]{},.;:!?]/g;
const QUOTED_RE = /"([^"\\\n]*)"|'([^'\\\n]*)'|`([^`\\\n]*)`/g;
/** Unquoted targets: Markdown `[x](path)` / `![x](path "title")`, CSS `url(path)`. */
const BARE_RE = /\]\(([^()\s]+)(?:\s+"[^"]*")?\)|\burl\(\s*([^()'"\s]+)\s*\)/g;
const MAX_PATH = 260;
/** Whether a string literal looks like a file path rather than prose or an identifier. */
export function looksLikePath(value: string): boolean {
if (!value || value.length > MAX_PATH || /^[a-z][\w+.-]*:/i.test(value) || /[\s<>|*?]/.test(value)) return false;
const bare = value.replace(/[?#].*$/, "");
return /\//.test(bare) || /^\.{0,2}[\w@~-][\w.@~-]*\.[A-Za-z0-9]{1,8}$/.test(bare);
}
/** A URL or path-like quoted string at `offset`, found on its line. */
export function textReference(text: Text, offset: number): Reference | null {
const line = text.lineAt(offset);
const col = offset - line.from;
for (const m of line.text.matchAll(URL_RE)) {
if (col >= m.index && col <= m.index + m[0].length)
return { kind: "url", from: line.from + m.index, to: line.from + m.index + m[0].length, url: m[0] };
}
const strings = [
...[...line.text.matchAll(QUOTED_RE)].map((m) => ({ value: m[1] ?? m[2] ?? m[3] ?? "", from: m.index + 1 })),
...[...line.text.matchAll(BARE_RE)].map((m) => {
const value = m[1] ?? m[2] ?? "";
return { value, from: m.index + m[0].indexOf(value) };
}),
];
for (const { value, from } of strings) {
if (col < from || col > from + value.length || !looksLikePath(value)) continue;
return { kind: "path", from: line.from + from, to: line.from + from + value.length, value };
}
return null;
}
function dirname(path: string): string {
const i = path.lastIndexOf("/");
return i > 0 ? path.slice(0, i) : "/";
}
/** `a/./b/../c` → `a/c`. */
export function normalizePath(path: string): string {
const out: string[] = [];
for (const seg of path.split("/")) {
if (seg === "." || (seg === "" && out.length)) continue;
if (seg === ".." && out.length > 1) out.pop();
else out.push(seg);
}
return out.join("/") || "/";
}
/**
* Where a path-like string may point, most likely first. `/x` resolves against
* the project root and its resource roots (`resourceRoots`, absolute), like IDEA.
*/
export function pathCandidates(value: string, from: string, root: string | null, resourceRoots: readonly string[] = []): string[] {
const bare = decodeURIComponentSafe(value.replace(/[?#].*$/, ""));
const list: string[] = [];
if (bare.startsWith("/")) {
if (root) list.push(`${root}${bare}`);
list.push(...resourceRoots.map((d) => `${d}${bare}`));
list.push(bare);
} else if (!bare.startsWith("~")) {
list.push(`${dirname(from)}/${bare}`);
if (root && !bare.startsWith("..")) list.push(`${root}/${bare}`);
}
return [...new Set(list.map(normalizePath))];
}
function decodeURIComponentSafe(s: string): string {
try {
return decodeURIComponent(s);
} catch {
return s;
}
}
async function isFile(path: string): Promise<boolean> {
if (!isTauri()) return false;
try {
await call(commands.fsProbe(path));
return true;
} catch {
return false;
}
}
/** Resolves a `path` reference to an existing file; null when none exists. */
export async function resolvePath(ref: Reference, from: string): Promise<Reference | null> {
if (ref.kind !== "path") return ref;
const root = useProject.getState().project?.root ?? null;
const resources = root
? currentRoots()
.filter((r) => r.kind === "resources")
.map((r) => (r.path ? `${root}/${r.path}` : root))
: [];
for (const path of pathCandidates(ref.value, from, root, resources))
if (await isFile(path)) return { kind: "file", from: ref.from, to: ref.to, path, fragment: ref.value.split("#")[1] ?? null };
return null;
}
// --- following ----------------------------------------------------------------
/** The offset a `#fragment` names in `text` of `path`: `L12[,5]`, an element id or a Markdown heading. */
export function fragmentOffset(text: string, path: string, fragment: string): number | null {
const line = /^L(\d+)(?:[,:C](\d+))?$/i.exec(fragment);
const lines = text.split("\n");
if (line) {
const n = Math.min(Number(line[1]) - 1, lines.length - 1);
if (n < 0) return null;
const col = Math.min(Math.max(Number(line[2] ?? 1) - 1, 0), lines[n]!.length);
return lines.slice(0, n).reduce((a, l) => a + l.length + 1, 0) + col;
}
const esc = fragment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (/\.(md|markdown|mdx)$/i.test(path)) {
let offset = 0;
for (const l of lines) {
const h = /^#{1,6}\s+(.*?)\s*#*$/.exec(l);
if (h && slug(h[1]!) === fragment.toLowerCase()) return offset;
offset += l.length + 1;
}
return null;
}
const m = new RegExp(`\\b(?:id|name)\\s*=\\s*["']?${esc}(?=["'\\s>])`).exec(text);
return m ? m.index : null;
}
const slug = (s: string) =>
s
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.trim()
.replace(/\s+/g, "-");
export async function followReference(ref: Reference): Promise<void> {
if (ref.kind === "missing") throw new Error(`Cannot find file '${ref.value}'`);
if (ref.kind === "url") {
await openExternalUrl(ref.url);
return;
}
if (ref.kind !== "file") return;
let selection: { line: number; col: number } | undefined;
if (ref.fragment) {
const text = await call(commands.fsReadText(ref.path)).catch(() => null);
const offset = text === null ? null : fragmentOffset(text, ref.path, ref.fragment);
if (text !== null && offset !== null) {
const before = text.slice(0, offset);
const line = before.split("\n").length - 1;
selection = { line, col: offset - (before.lastIndexOf("\n") + 1) };
}
}
await editorService().openFile(ref.path, { focus: true, ...(selection ? { selection } : {}) });
}
/**
* The reference at `offset`: a server link, else a URL or an existing file
* named by a quoted path. Null when there is none.
*/
export async function referenceAt(view: EditorView, doc: EditorDocContext, offset: number): Promise<Reference | null> {
const link = linkReference(view.state.doc, await documentLinks(view, doc), offset);
const ref = textReference(view.state.doc, offset);
if (link?.kind !== "file") {
if (link) return link;
if (!ref || doc.virtual) return ref?.kind === "url" ? ref : null;
return resolvePath(ref, doc.path);
}
if (await isFile(link.path)) return link;
// Servers resolve `/x` against the file system or workspace root; web roots (`public/`) are ours.
const own = ref && !doc.virtual ? await resolvePath(ref, doc.path) : null;
if (own) return own;
return { kind: "missing", from: link.from, to: link.to, value: view.state.sliceDoc(link.from, link.to) };
}
+80 -3
Переглянути файл
@@ -6,7 +6,15 @@
*/
import { EditorSelection, Prec, StateEffect, StateField, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, keymap, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import { commands, isCancelled, type GotoKind, type Highlight, type Location, type Pos } from "@/platform/ipc";
import {
commands,
isCancelled,
type GotoKind,
type Highlight,
type Location,
type Pos,
type WebReferenceKind,
} from "@/platform/ipc";
import {
CONTRIBUTED_DEFINITION,
activeEditor,
@@ -26,6 +34,7 @@ import { SYNTAX_NOTE, declarationFallback, isSelfTarget, localHighlights, type D
import { showHint } from "./hint";
import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore";
import { readManyFileLines } from "./files";
import { documentLinks, followReference, knownLinks, linkReference, referenceAt, textReference } from "./links";
import { serverBackoff } from "./availability";
import { editorPrefs } from "@/platform/editorSettings";
import { isVirtualUri, virtualContainer } from "@/platform/virtualDocs";
@@ -101,13 +110,18 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
const pos = offsetToPos(view.state.doc, offset);
let locs: DeclarationTarget[];
let fromSyntax = false;
const declaration = kind === "definition" || kind === "declaration";
let serverDown = false;
try {
locs = dedupeLocations(await lspRequest(() => commands.lspGoto(doc.doc, pos, kind)));
} catch (e) {
if (isCancelled(e) || (kind !== "definition" && kind !== "declaration")) {
if (isCancelled(e) || !declaration) {
reportLspError(view, e, true);
return;
}
// Web references and links need no server.
if ((await gotoWebReference(view, doc, pos, offset)) || (await followReferenceAt(view, doc, offset))) return;
serverDown = isUnavailable(e);
// Dumb mode: the server is not ready / failing — answer from the syntax tree.
const fb = await gotoFallback(view, doc, pos);
if (!fb) {
@@ -118,7 +132,14 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
fromSyntax = true;
}
if (!locs.length) {
showHint(view, NOT_FOUND[kind]);
// The server has no definition here: a link (`href`, `#include`, Markdown) or a file path.
if (
declaration &&
!fromSyntax &&
((await gotoWebReference(view, doc, pos, offset)) || (await followReferenceAt(view, doc, offset)))
)
return;
showHint(view, serverDown ? `${NOT_FOUND[kind]}: no language server is running for this file` : NOT_FOUND[kind]);
return;
}
// IDEA: ⌘B on a declaration shows its usages.
@@ -144,6 +165,52 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
});
}
const WEB_LANGUAGES = new Set(["html", "css", "scss", "less", "vue", "svelte"]);
const WEB_SIGIL: Record<WebReferenceKind, string> = { class: ".", id: "#", anchor: "#", cssVariable: "" };
/**
* `class` / `id` attributes to their CSS selectors, `href="#x"` to the element,
* `var(--x)` to its declarations (no language server resolves these); false
* when there is no such reference at `pos`.
*/
async function gotoWebReference(view: EditorView, doc: EditorDocContext, pos: Pos, offset: number): Promise<boolean> {
if (!WEB_LANGUAGES.has(doc.language) || doc.virtual) return false;
let found;
try {
await whenSynced(view);
found = await lspRequest(() => commands.webGoto(doc.doc, pos));
} catch (e) {
console.debug("[web] goto", e);
return false;
}
if (!found) return false;
const { reference, targets } = found;
const label = `${WEB_SIGIL[reference.kind]}${reference.name}`;
if (!targets.length) showHint(view, `Cannot find declaration of '${label}'`);
else if (targets.length === 1) await navigateTo(targets[0]!);
else
openPopup({
kind: "list",
anchor: caretAnchor(view, offset),
title: `Choose Declaration of '${label}' (${targets.length} found)`,
items: await locationItems(targets),
});
return true;
}
/** Follows the link, URL or file path at `offset`; false when there is none. */
async function followReferenceAt(view: EditorView, doc: EditorDocContext, offset: number): Promise<boolean> {
const ref = await referenceAt(view, doc, offset);
if (!ref) return false;
try {
await followReference(ref);
} catch (e) {
showHint(view, e instanceof Error ? e.message : String(e), { tone: "error" });
}
return true;
}
/** Declaration targets from the structure service; null when it cannot answer for this file. */
async function gotoFallback(view: EditorView, doc: EditorDocContext, pos: Pos): Promise<DeclarationTarget[] | null> {
try {
@@ -173,9 +240,13 @@ const linkField = StateField.define<DecorationSet>({
provide: (f) => EditorView.decorations.from(f),
});
/** What ⌘-hover underlines: a whole link, URL or path, else the word. */
function linkAt(view: EditorView, x: number, y: number) {
const pos = view.posAtCoords({ x, y }, false);
if (pos == null) return null;
const links = knownLinks(view);
const ref = (links && linkReference(view.state.doc, links, pos)) ?? textReference(view.state.doc, pos);
if (ref) return ref;
const word = view.state.wordAt(pos);
return word && word.from < word.to ? word : null;
}
@@ -185,6 +256,12 @@ const linkPlugin = ViewPlugin.fromClass(
last: { x: number; y: number } | null = null;
constructor(readonly view: EditorView) {}
show(meta: boolean) {
const doc = meta ? docOf(this.view) : null;
// Underline whole links once the server's links are in.
if (doc && !doc.virtual && !knownLinks(this.view))
void documentLinks(this.view, doc).then(() => {
if (this.view.state.field(linkField).size) this.show(true);
});
const current = this.view.state.field(linkField);
const target = meta && this.last ? linkAt(this.view, this.last.x, this.last.y) : null;
let same = false;
+11
Переглянути файл
@@ -0,0 +1,11 @@
import { isTauri } from "./ipc";
/** Opens a web or `mailto:` URL with the system handler (the default browser). */
export async function openExternalUrl(url: string): Promise<void> {
if (!isTauri()) {
window.open(url, "_blank", "noopener");
return;
}
const { openUrl } = await import("@tauri-apps/plugin-opener");
await openUrl(url);
}