Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions crates/language-server/src/backend/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use async_lsp::ClientSocket;
use common::InputDb;
use common::cache::remote_git_cache_dir;
use driver::DriverDataBase;
use rustc_hash::FxHashSet;
use std::path::{Path, PathBuf};
use url::Url;

use crate::builtin_files::BuiltinFiles;
Expand All @@ -11,13 +14,17 @@ pub struct Backend {
#[allow(dead_code)] // TODO: salsa3-compatible parallelism
pub(super) workers: tokio::runtime::Runtime,
pub(super) builtin_files: Option<BuiltinFiles>,
pub(super) git_cache_root: Option<PathBuf>,
pub(super) git_cache_uris: FxHashSet<Url>,
pub(super) non_git_cache_uris: FxHashSet<Url>,
pub(super) readonly_warnings: FxHashSet<Url>,
}

impl Backend {
pub fn new(client: ClientSocket) -> Self {
let db = DriverDataBase::default();
let builtin_files = BuiltinFiles::new(&db).ok();
let git_cache_root = remote_git_cache_dir().map(|path| path.as_std_path().to_path_buf());

let workers = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
Expand All @@ -29,6 +36,9 @@ impl Backend {
db,
workers,
builtin_files,
git_cache_root,
git_cache_uris: FxHashSet::default(),
non_git_cache_uris: FxHashSet::default(),
readonly_warnings: FxHashSet::default(),
}
}
Expand All @@ -52,4 +62,108 @@ impl Backend {
.as_ref()
.is_some_and(|builtins| builtins.is_tmp_uri(uri))
}

pub fn is_git_cache_uri(&self, uri: &Url) -> bool {
if self.git_cache_uris.contains(uri) {
return true;
}
if self.non_git_cache_uris.contains(uri) {
return false;
}
if uri.scheme() != "file" {
return false;
}
let Ok(path) = uri.to_file_path() else {
return false;
};
self.is_git_cache_path(&path) || self.is_remote_dependency_uri(uri)
}

pub fn is_git_cache_path(&self, path: &Path) -> bool {
if let Some(root) = self.git_cache_root.as_deref() {
if path.starts_with(root) {
return true;
}

// If the cache dir (or file path) is accessed via a symlinked alias (e.g. `/tmp`
// vs `/private/tmp`), a simple `starts_with` check can fail. Best-effort
// canonicalization keeps the resolver + editor paths aligned.
if let Ok(canonical_root) = std::fs::canonicalize(root)
&& let Ok(canonical_path) = std::fs::canonicalize(path)
&& canonical_path.starts_with(&canonical_root)
{
return true;
}
}
is_local_git_cache_path(path)
}

pub fn classify_git_cache_path(&mut self, uri: &Url, path: &Path) -> bool {
if self.git_cache_uris.contains(uri) {
return true;
}
if self.non_git_cache_uris.contains(uri) {
return false;
}

let is_git_cache = self.is_git_cache_path(path) || self.is_remote_dependency_uri(uri);
if is_git_cache {
self.git_cache_uris.insert(uri.clone());
} else {
self.non_git_cache_uris.insert(uri.clone());
}
is_git_cache
}

fn is_remote_dependency_uri(&self, uri: &Url) -> bool {
let Some(ingot) = self.db.workspace().containing_ingot(&self.db, uri.clone()) else {
return false;
};
let base = ingot.base(&self.db);
self.db
.dependency_graph()
.remote_git_for_local(&self.db, &base)
.is_some()
}
}

fn is_local_git_cache_path(path: &Path) -> bool {
let mut components = path.components().peekable();
while let Some(component) = components.next() {
if component.as_os_str() == ".fe"
&& components
.peek()
.is_some_and(|next| next.as_os_str() == "git")
{
return true;
}
}
false
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn detects_local_git_cache_dir() {
let path = PathBuf::from("workspace")
.join(".fe")
.join("git")
.join("checkout")
.join("src")
.join("lib.fe");
assert!(is_local_git_cache_path(&path));
}

#[test]
fn does_not_match_non_git_dirs() {
let path = PathBuf::from("workspace")
.join(".fe")
.join("not-git")
.join("checkout")
.join("src")
.join("lib.fe");
assert!(!is_local_git_cache_path(&path));
}
}
2 changes: 1 addition & 1 deletion crates/language-server/src/functionality/code_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ pub async fn handle_code_action(
params: CodeActionParams,
) -> Result<Option<CodeActionResponse>, ResponseError> {
let lsp_uri = params.text_document.uri.clone();
if backend.is_builtin_tmp_uri(&lsp_uri) {
if backend.is_builtin_tmp_uri(&lsp_uri) || backend.is_git_cache_uri(&lsp_uri) {
return Ok(None);
}

Expand Down
18 changes: 17 additions & 1 deletion crates/language-server/src/functionality/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,20 @@ pub async fn handle_file_change(
}
};

if backend.classify_git_cache_path(&message.uri, &path) {
if matches!(message.kind, ChangeKind::Edit(_))
&& backend.readonly_warnings.insert(message.uri.clone())
{
let _ = backend.client.clone().show_message(ShowMessageParams {
typ: MessageType::ERROR,
message:
"Git dependency cache files are read-only in the editor; edits are ignored."
.to_string(),
});
}
return Ok(());
}

let path_str = match path.to_str() {
Some(p) => p,
None => {
Expand Down Expand Up @@ -465,7 +479,9 @@ pub async fn handle_formatting(
backend: &Backend,
params: DocumentFormattingParams,
) -> Result<Option<Vec<TextEdit>>, ResponseError> {
if backend.is_builtin_tmp_uri(&params.text_document.uri) {
if backend.is_builtin_tmp_uri(&params.text_document.uri)
|| backend.is_git_cache_uri(&params.text_document.uri)
{
return Ok(None);
}

Expand Down
15 changes: 12 additions & 3 deletions crates/language-server/src/functionality/rename.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ pub async fn handle_rename(
"Renaming symbols in built-in library files is not supported.".to_string(),
));
}
if backend.is_git_cache_uri(&lsp_uri) {
return Err(ResponseError::new(
async_lsp::ErrorCode::INVALID_REQUEST,
"Renaming symbols in git dependency cache files is not supported.".to_string(),
));
}

let internal_url = backend.map_client_uri_to_internal(lsp_uri);
let Some(file) = backend.db.workspace().get(&backend.db, &internal_url) else {
Expand All @@ -50,11 +56,13 @@ pub async fn handle_rename(
.name_span(&backend.db)
.and_then(|span| span.resolve(&backend.db))
.and_then(|span| span.file.url(&backend.db))
.is_some_and(|url| url.scheme().starts_with("builtin-"))
.is_some_and(|url| {
url.scheme().starts_with("builtin-") || backend.is_git_cache_uri(&url)
})
{
return Err(ResponseError::new(
async_lsp::ErrorCode::INVALID_REQUEST,
"Renaming symbols defined in the built-in libraries is not supported.".to_string(),
"Renaming symbols defined in read-only library sources is not supported.".to_string(),
));
}

Expand Down Expand Up @@ -153,7 +161,8 @@ pub async fn handle_rename(
}

// Never propose edits to embedded built-in library sources.
changes.retain(|url, _| !url.scheme().starts_with("builtin-"));
changes
.retain(|url, _| !url.scheme().starts_with("builtin-") && !backend.is_git_cache_uri(url));

if changes.is_empty() {
Ok(None)
Expand Down
81 changes: 80 additions & 1 deletion crates/resolver/src/git.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::{fmt, fs, io};
use std::{fmt, fs, io, path::Path};

use camino::{Utf8Path, Utf8PathBuf};
use git2::{Repository, build::CheckoutBuilder};
Expand Down Expand Up @@ -92,12 +92,24 @@ impl GitResolver {
self.ensure_checkout_root()?;
let checkout_path = self.checkout_path(description);
let status = self.ensure_checkout(description, &checkout_path)?;
if let Err(error) = self.enforce_readonly(checkout_path.as_path()) {
tracing::warn!(
target: "resolver",
"Failed to mark git checkout read-only at {}: {}",
checkout_path,
error
);
}
Ok(GitResource {
reused_checkout: matches!(status, CheckoutStatus::Existing),
checkout_path,
})
}

pub fn enforce_readonly(&self, checkout_path: &Utf8Path) -> io::Result<()> {
enforce_readonly_recursive(checkout_path.as_std_path())
}

fn ensure_checkout_root(&self) -> Result<(), GitResolutionError> {
if !self.checkout_root.exists() {
fs::create_dir_all(self.checkout_root.as_std_path()).map_err(|source| {
Expand Down Expand Up @@ -223,11 +235,78 @@ impl GitResolver {
}
}

fn enforce_readonly_recursive(root: &Path) -> io::Result<()> {
for entry in fs::read_dir(root)? {
let entry = entry?;
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}

let file_name = entry.file_name();
if file_name.as_os_str() == std::ffi::OsStr::new(".git") {
continue;
}

let path = entry.path();
if file_type.is_dir() {
enforce_readonly_recursive(&path)?;
} else if file_type.is_file() {
make_file_readonly(&path)?;
}
}

Ok(())
}

#[cfg(unix)]
fn make_file_readonly(path: &Path) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;

let mut perms = fs::metadata(path)?.permissions();
let mode = perms.mode();
perms.set_mode(mode & !0o222);
fs::set_permissions(path, perms)?;

Ok(())
}

#[cfg(not(unix))]
fn make_file_readonly(path: &Path) -> io::Result<()> {
let mut perms = fs::metadata(path)?.permissions();
perms.set_readonly(true);
fs::set_permissions(path, perms)?;
Ok(())
}

enum CheckoutStatus {
Fresh,
Existing,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn enforce_readonly_marks_files_readonly() {
let dir = tempfile::tempdir().expect("temp dir");
let root = Utf8Path::from_path(dir.path()).expect("utf8 root");
let nested = root.join("nested");
fs::create_dir_all(nested.as_std_path()).expect("create nested dir");
let file_path = nested.join("file.txt");
fs::write(file_path.as_std_path(), "hi").expect("write file");

let resolver = GitResolver::new(root.to_owned());
resolver
.enforce_readonly(root)
.expect("set checkout readonly");

let metadata = fs::metadata(file_path.as_std_path()).expect("metadata");
assert!(metadata.permissions().readonly());
}
}

#[derive(Debug, Clone)]
pub enum GitResolutionEvent {
CheckoutStart {
Expand Down
8 changes: 6 additions & 2 deletions crates/resolver/src/git_stub.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::{error::Error, fmt};
use std::{error::Error, fmt, io};

use camino::Utf8PathBuf;
use camino::{Utf8Path, Utf8PathBuf};
use url::Url;

use crate::{ResolutionHandler, Resolver};
Expand Down Expand Up @@ -96,6 +96,10 @@ impl GitResolver {
) -> Result<GitResource, GitResolutionError> {
Err(GitResolutionError::UnsupportedTarget)
}

pub fn enforce_readonly(&self, _checkout_path: &Utf8Path) -> io::Result<()> {
Ok(())
}
}

#[derive(Debug, Clone)]
Expand Down
8 changes: 8 additions & 0 deletions crates/resolver/src/ingot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,14 @@ impl IngotResolverImpl {
{
let checkout_path = self.git.checkout_path(description);
if self.git.has_valid_cached_checkout(description) {
if let Err(error) = self.git.enforce_readonly(checkout_path.as_path()) {
tracing::warn!(
target: "resolver",
"Failed to mark git checkout read-only at {}: {}",
checkout_path,
error
);
}
return Ok((checkout_path, true, false));
}

Expand Down