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:
l0ng-ai committed 2026-10-24 23:28:20 +08:00
1 parent 87b463990c
commit 68dcea409c
16 files changed
+652 -74

No files matched your search

+1 -1
View File
@@ -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,
};
+6 -1
View File
@@ -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()
+16
View File
@@ -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));
}
+122
View File
@@ -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()
}
+212
View File
@@ -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)]);
}
}
+1
View File
@@ -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;
+5
View File
@@ -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
+3
View File
@@ -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,
+75 -3
View File
@@ -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
}
+18
View File
@@ -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). */
+2 -1
View File
@@ -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 },
+23
View File
@@ -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 });
});
});
+150
View File
@@ -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, []);
},
};
});
}
+2
View File
@@ -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 -14
View File
@@ -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
View File
@@ -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;