diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 67388a227..1320e490c 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -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, }; diff --git a/crates/ide-language/src/syntax/mod.rs b/crates/ide-language/src/syntax/mod.rs index ac4cb7daf..bd24fc77e 100644 --- a/crates/ide-language/src/syntax/mod.rs +++ b/crates/ide-language/src/syntax/mod.rs @@ -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 { + 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 { self.with_web_tree(lang, text, |_, _, tree| web::anchors(tree, text, name)).unwrap_or_default() diff --git a/crates/ide-language/src/syntax/tests.rs b/crates/ide-language/src/syntax/tests.rs index 68691e5c5..f61c8590a 100644 --- a/crates/ide-language/src/syntax/tests.rs +++ b/crates/ide-language/src/syntax/tests.rs @@ -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 { + syntax().web_file_references(&lang(id), src).into_iter().map(|r| r.value).collect() + }; + let html = concat!( + "\"x\"\n", + "t
\n", + "", + ); + 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)); +} diff --git a/crates/ide-language/src/syntax/web.rs b/crates/ide-language/src/syntax/web.rs index b221ae721..73c9896d4 100644 --- a/crates/ide-language/src/syntax/web.rs +++ b/crates/ide-language/src/syntax/web.rs @@ -250,3 +250,125 @@ pub(crate) fn anchors(tree: &Tree, text: &str, name: &str) -> Vec { }); 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 { + 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() +} diff --git a/crates/ide-workspace/src/file_refs.rs b/crates/ide-workspace/src/file_refs.rs new file mode 100644 index 000000000..d2484aa61 --- /dev/null +++ b/crates/ide-workspace/src/file_refs.rs @@ -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) + || ["{{", "${", "<%", "{%", " 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 { + // A file outside the project is not served from its roots. + let root = root.filter(|r| from.starts_with(r)); + let mut dirs: Vec = 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 { + 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 { + 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 { + 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 = 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::>(); + 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::>(); + 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)]); + } +} diff --git a/crates/ide-workspace/src/lib.rs b/crates/ide-workspace/src/lib.rs index 5e9aca7b9..ab8ac7592 100644 --- a/crates/ide-workspace/src/lib.rs +++ b/crates/ide-workspace/src/lib.rs @@ -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; diff --git a/crates/ide-workspace/src/roots/mod.rs b/crates/ide-workspace/src/roots/mod.rs index ec15be05c..126c5a520 100644 --- a/crates/ide-workspace/src/roots/mod.rs +++ b/crates/ide-workspace/src/roots/mod.rs @@ -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 { self.roots diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e3f462486..41a5ce6b3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -104,6 +104,9 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src-tauri/src/commands/nav.rs b/src-tauri/src/commands/nav.rs index eb20d9e1a..349862396 100644 --- a/src-tauri/src/commands/nav.rs +++ b/src-tauri/src/commands/nav.rs @@ -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) { + 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> { + 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> { + 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> { + 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 +} diff --git a/src/bindings.ts b/src/bindings.ts index 7ccc71a16..223f04fc7 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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(__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(__TAURI_INVOKE("path_completions", { from, prefix })), + /** Paths in an HTML document or stylesheet that name no existing file. */ + webUnresolvedReferences: (doc: DocId) => typedError(__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). */ diff --git a/src/editor/lsp/completion.ts b/src/editor/lsp/completion.ts index 201596233..4d25da4c8 100644 --- a/src/editor/lsp/completion.ts +++ b/src/editor/lsp/completion.ts @@ -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 }, diff --git a/src/editor/lsp/fileReferences.test.ts b/src/editor/lsp/fileReferences.test.ts new file mode 100644 index 000000000..163a2ca3e --- /dev/null +++ b/src/editor/lsp/fileReferences.test.ts @@ -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(' { + 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 }); + }); +}); diff --git a/src/editor/lsp/fileReferences.ts b/src/editor/lsp/fileReferences.ts new file mode 100644 index 000000000..a93123526 --- /dev/null +++ b/src/editor/lsp/fileReferences.ts @@ -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 }>(); +const LISTING_TTL = 3000; + +function listing(from: string, dirPrefix: string): Promise { + 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 { + 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 | 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, []); + }, + }; + }); +} diff --git a/src/editor/lsp/index.ts b/src/editor/lsp/index.ts index 4f2760a2e..206dcdb1c 100644 --- a/src/editor/lsp/index.ts +++ b/src/editor/lsp/index.ts @@ -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(), ]; } diff --git a/src/editor/lsp/links.test.ts b/src/editor/lsp/links.test.ts index 7d402c358..f4c47d79b 100644 --- a/src/editor/lsp/links.test.ts +++ b/src/editor/lsp/links.test.ts @@ -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(['']); diff --git a/src/editor/lsp/links.ts b/src/editor/lsp/links.ts index 8208435d3..675862c75 100644 --- a/src/editor/lsp/links.ts +++ b/src/editor/lsp/links.ts @@ -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 { if (!isTauri()) return false; try { @@ -151,18 +110,16 @@ async function isFile(path: string): Promise { } } -/** 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 { 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 { 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;