дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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 /).
Цей коміт міститься в:
1 джерело
046fd37064
коміт
87b463990c
20 files changed
+1060
-13
No files matched your search
@@ -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]) {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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`].
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)` / ``, 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) };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача