mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
feat(nav): Complete file paths and flag unresolved web file references
Path completion in href/src/url()/@import, Markdown links and path-like strings, folders first. HTML and CSS paths that name no file get a 'Cannot resolve file' warning. File references resolve in the backend like IDEA's: relative to the file, /-rooted against the project and resource roots.
This commit is contained in:
1 parent
87b463990c
commit
68dcea409c
16 files changed
+652
-74
No files matched your search
@@ -13,7 +13,7 @@ mod registry;
|
||||
mod syntax;
|
||||
pub use registry::LanguageRegistry;
|
||||
pub use syntax::{
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, Syntax, SyntaxCache,
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, Syntax, SyntaxCache,
|
||||
SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind,
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ 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};
|
||||
pub use web::{CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, WebReference, WebReferenceKind};
|
||||
|
||||
/// Texts larger than this are not parsed (minified bundles, generated dumps).
|
||||
pub const MAX_PARSE_BYTES: usize = 16 * 1024 * 1024;
|
||||
@@ -210,6 +210,11 @@ impl Syntax {
|
||||
self.with_web_tree(lang, text, |g, css, tree| web::reference_at(g, css, tree, text, pos)).flatten()
|
||||
}
|
||||
|
||||
/// File paths written in an HTML document or stylesheet (`href`, `src`, `srcset`, `url()`, `@import`).
|
||||
pub fn web_file_references(&self, lang: &LanguageId, text: &str) -> Vec<FileReference> {
|
||||
self.with_web_tree(lang, text, |g, css, tree| web::file_references(g, css, tree, text)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 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()
|
||||
|
||||
@@ -1154,3 +1154,19 @@ fn web_anchors_find_ids_and_names() {
|
||||
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)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_file_references_in_attributes_and_stylesheets() {
|
||||
let refs = |id: &str, src: &str| -> Vec<String> {
|
||||
syntax().web_file_references(&lang(id), src).into_iter().map(|r| r.value).collect()
|
||||
};
|
||||
let html = concat!(
|
||||
"<link href=\"css/a.css\"><img srcset=\"a.png 1x, b.png 2x\" src=img/c.png alt=\"x\">\n",
|
||||
"<a href=\"#top\" class=\"k\">t</a><div style=\"background: url(d.svg)\"></div>\n",
|
||||
"<style>@import \"e.css\"; .x { background: url('f.png') }</style>",
|
||||
);
|
||||
assert_eq!(refs("html", html), ["css/a.css", "a.png", "b.png", "img/c.png", "#top", "d.svg", "e.css", "f.png"]);
|
||||
assert_eq!(refs("css", "@import url(g.css);\n.y { mask: url(\"h.svg#i\") }"), ["g.css", "h.svg#i"]);
|
||||
let first = &syntax().web_file_references(&lang("html"), html)[0];
|
||||
assert_eq!((first.range.start.col, first.range.end.col), (12, 21));
|
||||
}
|
||||
@@ -250,3 +250,125 @@ pub(crate) fn anchors(tree: &Tree, text: &str, name: &str) -> Vec<Range> {
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A path written in a document: an HTML URL attribute, a CSS `url()` / `@import`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileReference {
|
||||
pub range: Range,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// HTML attributes whose value is a URL.
|
||||
const URL_ATTRIBUTES: &[&str] = &[
|
||||
"href",
|
||||
"src",
|
||||
"srcset",
|
||||
"action",
|
||||
"formaction",
|
||||
"poster",
|
||||
"data",
|
||||
"cite",
|
||||
"background",
|
||||
"manifest",
|
||||
"xlink:href",
|
||||
"longdesc",
|
||||
"icon",
|
||||
];
|
||||
|
||||
/// `url(…)` arguments and `@import` targets of a stylesheet: `(start, end)` byte ranges.
|
||||
fn css_paths(tree: &Tree, css_text: &str, out: &mut Vec<(usize, usize)>) {
|
||||
let target = |n: Node| -> Option<(usize, usize)> {
|
||||
let n =
|
||||
if n.kind() == "string_value" { n.named_child(0).filter(|c| c.kind() == "string_content")? } else { n };
|
||||
matches!(n.kind(), "plain_value" | "string_content").then(|| (n.start_byte(), n.end_byte()))
|
||||
};
|
||||
walk(tree.root_node(), &mut |n| {
|
||||
match n.kind() {
|
||||
"import_statement" => {
|
||||
if let Some(first) = n.named_child(0)
|
||||
&& first.kind() == "string_value"
|
||||
{
|
||||
out.extend(target(first));
|
||||
}
|
||||
}
|
||||
"call_expression" => {
|
||||
let is_url =
|
||||
n.named_child(0).is_some_and(|f| f.kind() == "function_name" && text_of(f, css_text) == "url");
|
||||
if is_url
|
||||
&& let Some(args) = n.child_by_field_name("arguments").or_else(|| n.named_child(1))
|
||||
&& let Some(arg) = args.named_child(0)
|
||||
{
|
||||
out.extend(target(arg));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
/// File paths written in an HTML document or a stylesheet.
|
||||
pub(crate) fn file_references(g: &Grammar, css: Option<&Grammar>, tree: &Tree, text: &str) -> Vec<FileReference> {
|
||||
let idx = LineIndex::new(text);
|
||||
let mut spans: Vec<(usize, usize)> = Vec::new();
|
||||
for (base, css_text) in css_regions(g.spec.name, tree, text) {
|
||||
let parsed;
|
||||
let css_tree = if g.spec.name == "css" {
|
||||
tree
|
||||
} else {
|
||||
let Some(t) = css.and_then(|c| parse(&c.language, css_text, None)) else { continue };
|
||||
parsed = t;
|
||||
&parsed
|
||||
};
|
||||
let mut found = Vec::new();
|
||||
css_paths(css_tree, css_text, &mut found);
|
||||
spans.extend(found.into_iter().map(|(s, e)| (base + s, base + e)));
|
||||
}
|
||||
if g.spec.name == "html" {
|
||||
walk(tree.root_node(), &mut |n| {
|
||||
if n.kind() != "attribute" {
|
||||
return true;
|
||||
}
|
||||
let name = n.child(0).map(|a| text_of(a, text).to_ascii_lowercase()).unwrap_or_default();
|
||||
let mut value = None;
|
||||
walk(n, &mut |v| {
|
||||
if v.kind() == "attribute_value" {
|
||||
value = Some(v);
|
||||
}
|
||||
value.is_none()
|
||||
});
|
||||
let Some(v) = value else { return false };
|
||||
let (vs, vtext) = (v.start_byte(), text_of(v, text));
|
||||
match name.as_str() {
|
||||
"srcset" => {
|
||||
// `a.png 1x, b.png 2x`: the URL of each candidate.
|
||||
let mut at = 0;
|
||||
for part in vtext.split(',') {
|
||||
let lead = part.len() - part.trim_start().len();
|
||||
let url = part.split_whitespace().next().unwrap_or_default();
|
||||
if !url.is_empty() {
|
||||
spans.push((vs + at + lead, vs + at + lead + url.len()));
|
||||
}
|
||||
at += part.len() + 1;
|
||||
}
|
||||
}
|
||||
"style" => {
|
||||
let wrapped = format!("x{{{vtext}}}");
|
||||
if let Some(t) = css.and_then(|c| parse(&c.language, &wrapped, None)) {
|
||||
let mut found = Vec::new();
|
||||
css_paths(&t, &wrapped, &mut found);
|
||||
spans.extend(found.into_iter().map(|(s, e)| (vs + s - 2, vs + e - 2)));
|
||||
}
|
||||
}
|
||||
_ if URL_ATTRIBUTES.contains(&name.as_str()) && !vtext.trim().is_empty() => {
|
||||
spans.push((vs, vs + vtext.len()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
});
|
||||
}
|
||||
spans.sort_unstable();
|
||||
spans.into_iter().map(|(s, e)| FileReference { range: idx.range(s, e), value: text[s..e].to_owned() }).collect()
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! File references in text (`href="css/a.css"`, `url(../img/x.png)`,
|
||||
//! `open("data/x.csv")`), resolved like IDEA's file references: relative to the
|
||||
//! referencing file, `/`-rooted paths against the project root and its resource
|
||||
//! roots (`public/`, `src/main/resources`), which web servers serve at `/`.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
/// Whether `value` names a local file at all (not a URL, an anchor, a data URI
|
||||
/// or a template expression), so an unresolved one is worth reporting.
|
||||
pub fn is_file_reference(value: &str) -> bool {
|
||||
let v = value.trim();
|
||||
!(v.is_empty()
|
||||
|| v.starts_with('#')
|
||||
|| v.starts_with("//")
|
||||
|| v.starts_with('~')
|
||||
|| has_scheme(v)
|
||||
|| ["{{", "${", "<%", "{%", "<?", "@{"].iter().any(|t| v.contains(t)))
|
||||
}
|
||||
|
||||
/// `http:`, `mailto:`, `data:`, `javascript:`… (a Windows drive `C:` is a path).
|
||||
fn has_scheme(v: &str) -> bool {
|
||||
match v.find(':') {
|
||||
Some(i) if i > 1 => v[..i].chars().all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The path part: no `?query` / `#fragment`, percent-decoded.
|
||||
pub fn path_part(value: &str) -> String {
|
||||
let bare = value.trim().split(['?', '#']).next().unwrap_or_default();
|
||||
percent_decode(bare)
|
||||
}
|
||||
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%'
|
||||
&& let Some(b) = s.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok())
|
||||
{
|
||||
out.push(b);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_else(|_| s.to_owned())
|
||||
}
|
||||
|
||||
/// Removes `.` and resolves `..` lexically.
|
||||
fn normalize(path: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for c in path.components() {
|
||||
match c {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if !out.pop() {
|
||||
out.push("..");
|
||||
}
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Directories a reference's path is looked up in, most likely first.
|
||||
pub fn base_dirs(path: &str, from: &Path, root: Option<&Path>, resource_roots: &[PathBuf]) -> Vec<PathBuf> {
|
||||
// A file outside the project is not served from its roots.
|
||||
let root = root.filter(|r| from.starts_with(r));
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
if path.starts_with('/') {
|
||||
dirs.extend(root.map(Path::to_path_buf));
|
||||
if root.is_some() {
|
||||
dirs.extend(resource_roots.iter().cloned());
|
||||
}
|
||||
dirs.push(PathBuf::from("/"));
|
||||
} else {
|
||||
dirs.extend(from.parent().map(Path::to_path_buf));
|
||||
// `img/a.png` from a page served at the root; never for `./` or `../`.
|
||||
if !path.starts_with('.') {
|
||||
dirs.extend(root.map(Path::to_path_buf));
|
||||
}
|
||||
}
|
||||
let mut seen = Vec::new();
|
||||
dirs.retain(|d| {
|
||||
let fresh = !seen.contains(d);
|
||||
seen.push(d.clone());
|
||||
fresh
|
||||
});
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Paths `value` may point at, most likely first.
|
||||
pub fn candidates(value: &str, from: &Path, root: Option<&Path>, resource_roots: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let path = path_part(value);
|
||||
let rel = path.trim_start_matches('/');
|
||||
base_dirs(&path, from, root, resource_roots).iter().map(|d| normalize(&d.join(rel))).collect()
|
||||
}
|
||||
|
||||
/// The existing file or directory `value` names, if any.
|
||||
pub fn resolve(value: &str, from: &Path, root: Option<&Path>, resource_roots: &[PathBuf]) -> Option<PathBuf> {
|
||||
if !is_file_reference(value) || path_part(value).is_empty() {
|
||||
return None;
|
||||
}
|
||||
candidates(value, from, root, resource_roots).into_iter().find(|p| p.exists())
|
||||
}
|
||||
|
||||
/// An entry offered when completing a path.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PathCompletion {
|
||||
pub name: String,
|
||||
pub dir: bool,
|
||||
}
|
||||
|
||||
/// Entries of the directories `prefix` (the path typed so far) continues in:
|
||||
/// the part before its last `/`, looked up like a reference. Dot files only
|
||||
/// when the last segment starts with `.`.
|
||||
pub fn complete(prefix: &str, from: &Path, root: Option<&Path>, resource_roots: &[PathBuf]) -> Vec<PathCompletion> {
|
||||
let prefix = percent_decode(prefix);
|
||||
let (dir_part, last) = prefix.rsplit_once('/').map_or(("", prefix.as_str()), |(d, l)| (d, l));
|
||||
let dir_value =
|
||||
if prefix.starts_with('/') { format!("/{}", dir_part.trim_start_matches('/')) } else { dir_part.to_owned() };
|
||||
let show_hidden = last.starts_with('.');
|
||||
let rel = dir_value.trim_start_matches('/');
|
||||
let mut out: Vec<PathCompletion> = Vec::new();
|
||||
for base in base_dirs(&dir_value, from, root, resource_roots) {
|
||||
// The file system root only as a last resort for `/` paths.
|
||||
if base == Path::new("/") && !out.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(normalize(&base.join(rel))) else { continue };
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if (name.starts_with('.') && !show_hidden) || out.iter().any(|c| c.name == name) {
|
||||
continue;
|
||||
}
|
||||
let dir = e.file_type().is_ok_and(|t| t.is_dir()) || e.path().is_dir();
|
||||
out.push(PathCompletion { name, dir });
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.dir.cmp(&a.dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn what_counts_as_a_file_reference() {
|
||||
for v in ["a.css", "./a", "../img/x.png", "/splash.css", "a%20b.png?v=1#x", "C:/x"] {
|
||||
assert!(is_file_reference(v), "{v}");
|
||||
}
|
||||
for v in [
|
||||
"",
|
||||
"#top",
|
||||
"https://x.org",
|
||||
"//cdn.x/a.js",
|
||||
"mailto:a@b",
|
||||
"data:image/png;base64,x",
|
||||
"{{ url }}",
|
||||
"${base}/a.js",
|
||||
"~/x",
|
||||
] {
|
||||
assert!(!is_file_reference(v), "{v}");
|
||||
}
|
||||
assert_eq!(path_part(" img/a%20b.png?v=2#i "), "img/a b.png");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn candidates_follow_ideas_rules() {
|
||||
let from = Path::new("/p/web/pages/index.html");
|
||||
let root = Some(Path::new("/p"));
|
||||
let res = [PathBuf::from("/p/public")];
|
||||
let s = |v| candidates(v, from, root, &res).into_iter().map(|p| p.display().to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(s("../img/a.png"), ["/p/web/img/a.png"]);
|
||||
assert_eq!(s("img/a.png"), ["/p/web/pages/img/a.png", "/p/img/a.png"]);
|
||||
assert_eq!(s("/splash.css"), ["/p/splash.css", "/p/public/splash.css", "/splash.css"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_and_completes_against_the_file_system() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
for f in ["web/index.html", "web/css/a.css", "web/css/.hidden", "public/splash.css", "public/img/logo.svg"] {
|
||||
let p = root.join(f);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, "").unwrap();
|
||||
}
|
||||
let from = root.join("web/index.html");
|
||||
let res = [root.join("public")];
|
||||
let r = |v| resolve(v, &from, Some(root), &res);
|
||||
assert_eq!(r("css/a.css?v=1"), Some(root.join("web/css/a.css")));
|
||||
assert_eq!(r("/splash.css"), Some(root.join("public/splash.css")));
|
||||
assert_eq!(r("css/missing.css"), None);
|
||||
assert_eq!(r("https://x.org/a.css"), None);
|
||||
|
||||
let names = |p| complete(p, &from, Some(root), &res).into_iter().map(|c| (c.name, c.dir)).collect::<Vec<_>>();
|
||||
assert_eq!(names("css/"), [("a.css".into(), false)]);
|
||||
assert_eq!(names("css/."), [(".hidden".into(), false), ("a.css".into(), false)]);
|
||||
assert_eq!(
|
||||
names("/im"),
|
||||
[("img".into(), true), ("public".into(), true), ("web".into(), true), ("splash.css".into(), false)]
|
||||
);
|
||||
assert_eq!(names("/img/"), [("logo.svg".into(), false)]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod documents;
|
||||
pub mod editorconfig;
|
||||
pub mod file_defaults;
|
||||
pub mod file_moves;
|
||||
pub mod file_refs;
|
||||
pub mod history;
|
||||
pub mod memory;
|
||||
pub mod process;
|
||||
|
||||
@@ -107,6 +107,11 @@ impl ProjectRoots {
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective resource folders (project-relative): served at `/` for web file references.
|
||||
pub fn resource_dirs(&self) -> Vec<&str> {
|
||||
self.roots.iter().filter(|r| r.kind == RootKind::Resources).map(|r| r.path.as_str()).collect()
|
||||
}
|
||||
|
||||
/// Effective excluded folders as anchored globs (`/target`), for the index, watcher and search.
|
||||
pub fn excluded_globs(&self) -> Vec<String> {
|
||||
self.roots
|
||||
|
||||
@@ -104,6 +104,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
nav::mark_recent,
|
||||
nav::recent_files,
|
||||
nav::web_goto,
|
||||
nav::fs_resolve_reference,
|
||||
nav::path_completions,
|
||||
nav::web_unresolved_references,
|
||||
search::search_start,
|
||||
search::replace_in_files,
|
||||
search::replace_preview,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use super::doc::blocking;
|
||||
use crate::state::AppState;
|
||||
use ide_core::{DocId, IdeResult, Location, Pos};
|
||||
use crate::state::{AppState, Project};
|
||||
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
|
||||
use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
|
||||
use ide_language::WebReference;
|
||||
use ide_language::{FileReference, WebReference};
|
||||
use ide_workspace::file_refs::{self, PathCompletion};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::PathBuf;
|
||||
@@ -96,3 +97,74 @@ pub async fn web_goto(window: Window, state: State<'_, AppState>, doc: DocId, po
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The project root and its resource roots (served at `/`), for file references.
|
||||
fn reference_roots(project: &Project) -> (PathBuf, Vec<PathBuf>) {
|
||||
let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect();
|
||||
(project.root.clone(), resources)
|
||||
}
|
||||
|
||||
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn fs_resolve_reference(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
from: IpcPath,
|
||||
value: String,
|
||||
) -> IdeResult<Option<IpcPath>> {
|
||||
let project = state.project(window.label()).ok();
|
||||
blocking(move || {
|
||||
let (root, resources) = project.as_deref().map(reference_roots).unzip();
|
||||
let resources = resources.unwrap_or_default();
|
||||
Ok(file_refs::resolve(&value, &from, root.as_deref(), &resources).map(Into::into))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Files and folders that continue `prefix`, a path being typed in `from`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn path_completions(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
from: IpcPath,
|
||||
prefix: String,
|
||||
) -> IdeResult<Vec<PathCompletion>> {
|
||||
let project = state.project(window.label()).ok();
|
||||
blocking(move || {
|
||||
let (root, resources) = project.as_deref().map(reference_roots).unzip();
|
||||
let resources = resources.unwrap_or_default();
|
||||
Ok(file_refs::complete(&prefix, &from, root.as_deref(), &resources))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Paths in an HTML document or stylesheet that name no existing file.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn web_unresolved_references(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
doc: DocId,
|
||||
) -> IdeResult<Vec<FileReference>> {
|
||||
let project = state.project(window.label())?;
|
||||
let syntax = state.syntax();
|
||||
blocking(move || {
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (root, resources) = reference_roots(&project);
|
||||
Ok(syntax
|
||||
.web_file_references(&snap.language, &snap.text)
|
||||
.into_iter()
|
||||
.filter(|r| {
|
||||
file_refs::is_file_reference(&r.value)
|
||||
&& !file_refs::path_part(&r.value).is_empty()
|
||||
&& file_refs::resolve(&r.value, &snap.path, Some(&root), &resources).is_none()
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -191,6 +191,12 @@ export const commands = {
|
||||
reference: WebReference,
|
||||
targets: Location[],
|
||||
} | null, IdeError>(__TAURI_INVOKE("web_goto", { doc, pos })),
|
||||
/** The existing file or directory a path written in `from` names (`href`, `url()`, a string literal). */
|
||||
fsResolveReference: (from: IpcPath, value: string) => typedError<string | null, IdeError>(__TAURI_INVOKE("fs_resolve_reference", { from, value })),
|
||||
/** Files and folders that continue `prefix`, a path being typed in `from`. */
|
||||
pathCompletions: (from: IpcPath, prefix: string) => typedError<PathCompletion[], IdeError>(__TAURI_INVOKE("path_completions", { from, prefix })),
|
||||
/** Paths in an HTML document or stylesheet that name no existing file. */
|
||||
webUnresolvedReferences: (doc: DocId) => typedError<FileReference[], IdeError>(__TAURI_INVOKE("web_unresolved_references", { doc })),
|
||||
/**
|
||||
* Find in Files. Results stream through `on_event`; ends with `Done`.
|
||||
* Cancel with `cancel_request(request)`.
|
||||
@@ -1918,6 +1924,12 @@ export type FileProbe = {
|
||||
tooLarge: boolean,
|
||||
};
|
||||
|
||||
/** A path written in a document: an HTML URL attribute, a CSS `url()` / `@import`. */
|
||||
export type FileReference = {
|
||||
range: Range,
|
||||
value: string,
|
||||
};
|
||||
|
||||
/** Reference updates for a file / folder move (`workspace/willRenameFiles`). */
|
||||
export type FileRenameEdits = {
|
||||
/** Edits of every server that took part, in terms of the paths before the move. */
|
||||
@@ -2863,6 +2875,12 @@ export type PatchSource =
|
||||
/** Changes made by these commits (oldest first). */
|
||||
{ from: "commits"; commits: string[]; reverse: boolean };
|
||||
|
||||
/** An entry offered when completing a path. */
|
||||
export type PathCompletion = {
|
||||
name: string,
|
||||
dir: boolean,
|
||||
};
|
||||
|
||||
/** Capabilities a plugin must declare to use the gated host functions. */
|
||||
export type Permission =
|
||||
/** `workspace-root`, `read-file` (inside the project root only). */
|
||||
|
||||
@@ -31,6 +31,7 @@ import { isEmptyMarkup, renderCode, renderMarkup } from "./markdown";
|
||||
import { iconNode } from "./iconMarkup";
|
||||
import { triggerSignatureHelp } from "./signature";
|
||||
import { serverBackoff } from "./availability";
|
||||
import { pathCompletionSource } from "./fileReferences";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
|
||||
interface RequestContext {
|
||||
@@ -359,7 +360,7 @@ const completionFooter = ViewPlugin.fromClass(
|
||||
export function completionExtension() {
|
||||
return [
|
||||
autocompletion({
|
||||
override: [gated(lspCompletionSource), gated(contributedSource)],
|
||||
override: [gated(lspCompletionSource), gated(contributedSource), gated(pathCompletionSource)],
|
||||
icons: false,
|
||||
addToOptions: [
|
||||
{ render: kindBadge, position: 20 },
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pathContext, pathLike } from "./fileReferences";
|
||||
|
||||
describe("path completion context", () => {
|
||||
it("completes URL attributes, url(), @import and Markdown links eagerly", () => {
|
||||
expect(pathContext('<link rel="stylesheet" href="css/', "html")).toEqual({ prefix: "css/", eager: true });
|
||||
expect(pathContext("<img src=", "html")).toEqual({ prefix: "", eager: true });
|
||||
expect(pathContext(".a { background: url(../img/lo", "css")).toEqual({ prefix: "../img/lo", eager: true });
|
||||
expect(pathContext('@import "the', "scss")).toEqual({ prefix: "the", eager: true });
|
||||
expect(pathContext("See [docs](guide/", "markdown")).toEqual({ prefix: "guide/", eager: true });
|
||||
});
|
||||
|
||||
it("completes string literals only once they look like paths", () => {
|
||||
expect(pathContext('open("data/x', "python")).toEqual({ prefix: "data/x", eager: false });
|
||||
expect(pathLike("data/x") && pathLike("./") && pathLike("../a")).toBe(true);
|
||||
expect(pathLike("hello")).toBe(false);
|
||||
// Closed strings and module specifiers (the language server's job) are left alone.
|
||||
expect(pathContext('log("done") + x', "python")).toBeNull();
|
||||
expect(pathContext('import { a } from "./ut', "typescript")).toBeNull();
|
||||
expect(pathContext('const m = await import("./m', "javascript")).toBeNull();
|
||||
expect(pathContext('fetch("./data/', "typescript")).toEqual({ prefix: "./data/", eager: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* File references while editing, in every language: path completion inside
|
||||
* `href="…"`, `src="…"`, `url(…)`, `@import "…"`, Markdown links and path-like
|
||||
* string literals (folders first; picking one continues into it), and the
|
||||
* "Cannot resolve file" warning on HTML / CSS paths that name no file.
|
||||
* Paths resolve in the backend like IDEA's file references: relative to the
|
||||
* file, `/…` against the project root and its resource roots.
|
||||
*/
|
||||
import { startCompletion, type Completion, type CompletionContext, type CompletionResult } from "@codemirror/autocomplete";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { EditorView, ViewPlugin } from "@codemirror/view";
|
||||
import { whenSynced } from "@/editor/api";
|
||||
import { call, commands, isTauri, onIdeEvent, type PathCompletion } from "@/platform/ipc";
|
||||
import { fileIconName, folderIconName } from "@/ui";
|
||||
import { setFileDiagnostics } from "./diagnostics";
|
||||
import { docOf, lspDoc } from "./lsp";
|
||||
|
||||
// --- completion ---------------------------------------------------------------
|
||||
|
||||
export interface PathContext {
|
||||
/** The path typed so far. */
|
||||
prefix: string;
|
||||
/** Whether to complete without an explicit request even when `prefix` does not look like a path yet. */
|
||||
eager: boolean;
|
||||
}
|
||||
|
||||
const URL_ATTRIBUTE = /\b(?:href|src|srcset|action|formaction|poster|data|xlink:href)\s*=\s*["']?([^"'\s>]*)$/i;
|
||||
const CSS_URL = /\burl\(\s*["']?([^"')\s]*)$/;
|
||||
const CSS_IMPORT = /@(?:import|use|forward)\s+["']([^"']*)$/;
|
||||
const MARKDOWN_LINK = /\]\(([^)\s]*)$/;
|
||||
const STRING = /(["'`])([^"'`\\]*)$/;
|
||||
/** Module specifiers: the language server completes those. */
|
||||
const MODULE_SPECIFIER = /(?:\bimport\b|\bfrom\b|\brequire\s*\(|\bimport\s*\()\s*["'`][^"'`]*$/;
|
||||
const JS_LIKE = new Set(["javascript", "typescript", "javascriptreact", "typescriptreact", "vue", "svelte"]);
|
||||
|
||||
/** Whether `prefix` already reads like a path (`./`, `../`, `a/b`). */
|
||||
export const pathLike = (prefix: string) => /^\.{1,2}\/|\//.test(prefix);
|
||||
|
||||
/** The path being typed at the end of `before` (the line up to the caret), if any. */
|
||||
export function pathContext(before: string, language: string): PathContext | null {
|
||||
for (const re of [URL_ATTRIBUTE, CSS_URL, CSS_IMPORT, MARKDOWN_LINK]) {
|
||||
const m = re.exec(before);
|
||||
if (m) return { prefix: m[1]!, eager: true };
|
||||
}
|
||||
const m = STRING.exec(before);
|
||||
if (!m) return null;
|
||||
// Inside a string only when the quote opens one (an odd count on the line).
|
||||
if ((before.split(m[1]!).length - 1) % 2 === 0) return null;
|
||||
if (JS_LIKE.has(language) && MODULE_SPECIFIER.test(before)) return null;
|
||||
return { prefix: m[2]!, eager: false };
|
||||
}
|
||||
|
||||
const listings = new Map<string, { at: number; items: Promise<PathCompletion[]> }>();
|
||||
const LISTING_TTL = 3000;
|
||||
|
||||
function listing(from: string, dirPrefix: string): Promise<PathCompletion[]> {
|
||||
const key = `${from}\n${dirPrefix}`;
|
||||
const hit = listings.get(key);
|
||||
if (hit && Date.now() - hit.at < LISTING_TTL) return hit.items;
|
||||
const items = call(commands.pathCompletions(from, dirPrefix)).catch(() => []);
|
||||
listings.set(key, { at: Date.now(), items });
|
||||
if (listings.size > 32) listings.delete(listings.keys().next().value!);
|
||||
return items;
|
||||
}
|
||||
|
||||
function option(entry: PathCompletion): Completion & { icon: string } {
|
||||
if (!entry.dir) return { label: entry.name, type: "file", icon: fileIconName(entry.name), boost: -1 };
|
||||
return {
|
||||
label: `${entry.name}/`,
|
||||
type: "folder",
|
||||
icon: folderIconName(),
|
||||
apply: (view, _c, from, to) => {
|
||||
view.dispatch({ changes: { from, to, insert: `${entry.name}/` }, selection: { anchor: from + entry.name.length + 1 } });
|
||||
startCompletion(view);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function pathCompletionSource(ctx: CompletionContext): Promise<CompletionResult | null> {
|
||||
const doc = ctx.state.facet(lspDoc);
|
||||
if (!doc || doc.virtual || doc.readOnly || !isTauri()) return null;
|
||||
const line = ctx.state.doc.lineAt(ctx.pos);
|
||||
const found = pathContext(line.text.slice(0, ctx.pos - line.from), doc.language);
|
||||
if (!found || /[?#]/.test(found.prefix)) return null;
|
||||
if (!ctx.explicit && !found.eager && !pathLike(found.prefix)) return null;
|
||||
const slash = found.prefix.lastIndexOf("/");
|
||||
const entries = await listing(doc.path, found.prefix.slice(0, slash + 1));
|
||||
if (ctx.aborted || !entries.length) return null;
|
||||
return {
|
||||
from: ctx.pos - (found.prefix.length - slash - 1),
|
||||
options: entries.map(option),
|
||||
validFor: /^[^/"'`)\s?#]*$/,
|
||||
};
|
||||
}
|
||||
|
||||
// --- unresolved references --------------------------------------------------
|
||||
|
||||
const WEB_LANGUAGES = new Set(["html", "css", "scss", "less", "vue", "svelte"]);
|
||||
const SOURCE = "file-references";
|
||||
const INSPECT_DELAY = 600;
|
||||
|
||||
/** Warns about HTML / CSS paths that name no file; re-checked on edits and file-system changes. */
|
||||
export function unresolvedReferences(): Extension {
|
||||
return ViewPlugin.define((view: EditorView) => {
|
||||
const doc = docOf(view);
|
||||
if (!doc || doc.virtual || !WEB_LANGUAGES.has(doc.language) || !isTauri()) return {};
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let seq = 0;
|
||||
const run = async () => {
|
||||
const mine = ++seq;
|
||||
try {
|
||||
await whenSynced(view);
|
||||
const refs = await call(commands.webUnresolvedReferences(doc.doc));
|
||||
if (mine !== seq) return;
|
||||
setFileDiagnostics(
|
||||
doc.path,
|
||||
SOURCE,
|
||||
refs.map((r) => ({
|
||||
range: r.range,
|
||||
severity: "warning",
|
||||
message: `Cannot resolve file '${r.value}'`,
|
||||
source: doc.language === "html" || doc.language === "vue" || doc.language === "svelte" ? "HTML" : "CSS",
|
||||
code: null,
|
||||
})),
|
||||
);
|
||||
} catch (e) {
|
||||
console.debug("[file references]", e);
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => void run(), INSPECT_DELAY);
|
||||
};
|
||||
schedule();
|
||||
const offFs = onIdeEvent("fsChanged", (e) => {
|
||||
if (e.created.length || e.removed.length) schedule();
|
||||
});
|
||||
return {
|
||||
update: (u) => {
|
||||
if (u.docChanged) schedule();
|
||||
},
|
||||
destroy: () => {
|
||||
clearTimeout(timer);
|
||||
offFs();
|
||||
seq++;
|
||||
setFileDiagnostics(doc.path, SOURCE, []);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { registerActionsOnSave, registerFormatOnSave, registerLspActions } from
|
||||
import { lspTheme } from "./theme";
|
||||
import { inspectionWidgetExtension } from "./inspectionWidget";
|
||||
import { LspPopups } from "./Popups";
|
||||
import { unresolvedReferences } from "./fileReferences";
|
||||
import { prefetchMarkdown } from "./markdown";
|
||||
import { registerNavigationActions } from "@/editor/navigation";
|
||||
|
||||
@@ -44,6 +45,7 @@ export function lspExtensions(ctx: EditorDocContext): Extension[] {
|
||||
semanticExtension(),
|
||||
usagesLensExtension(),
|
||||
autoImportExtension(),
|
||||
unresolvedReferences(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Text } from "@codemirror/state";
|
||||
import { fragmentOffset, linkReference, looksLikePath, normalizePath, pathCandidates, textReference } from "./links";
|
||||
import { fragmentOffset, linkReference, looksLikePath, textReference } from "./links";
|
||||
|
||||
const at = (src: string, marker = "|") => {
|
||||
const offset = src.indexOf(marker);
|
||||
@@ -40,19 +40,6 @@ describe("text references", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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">']);
|
||||
|
||||
+15
-54
@@ -11,8 +11,7 @@ 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 { executeAction } from "@/platform/actions";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
import { lspRequest } from "./lsp";
|
||||
import { rangeToOffsets } from "./positions";
|
||||
@@ -22,6 +21,8 @@ export type Reference =
|
||||
| { 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 folder: revealed in the project view. */
|
||||
| { kind: "folder"; from: number; to: number; path: string }
|
||||
/** A link whose file does not exist (reported, never opened). */
|
||||
| { kind: "missing"; from: number; to: number; value: string };
|
||||
|
||||
@@ -99,48 +100,6 @@ export function textReference(text: Text, offset: number): Reference | null {
|
||||
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 {
|
||||
@@ -151,18 +110,16 @@ async function isFile(path: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves a `path` reference to an existing file; null when none exists. */
|
||||
/** Resolves a `path` reference (in the backend, like IDEA's file references); null when nothing exists there. */
|
||||
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;
|
||||
if (!isTauri()) return null;
|
||||
const path = await call(commands.fsResolveReference(from, ref.value)).catch(() => null);
|
||||
if (!path) return null;
|
||||
const fragment = ref.value.split("#")[1] ?? null;
|
||||
return (await isFile(path))
|
||||
? { kind: "file", from: ref.from, to: ref.to, path, fragment }
|
||||
: { kind: "folder", from: ref.from, to: ref.to, path };
|
||||
}
|
||||
|
||||
// --- following ----------------------------------------------------------------
|
||||
@@ -200,6 +157,10 @@ const slug = (s: string) =>
|
||||
|
||||
export async function followReference(ref: Reference): Promise<void> {
|
||||
if (ref.kind === "missing") throw new Error(`Cannot find file '${ref.value}'`);
|
||||
if (ref.kind === "folder") {
|
||||
await executeAction("navigate.selectIn", { path: ref.path });
|
||||
return;
|
||||
}
|
||||
if (ref.kind === "url") {
|
||||
await openExternalUrl(ref.url);
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user