Skip to content

Commit d6e086d

Browse files
authored
fix(lsp): handle watched files events from symlinked config files (#19898)
Related to denoland/vscode_deno#784
1 parent da70972 commit d6e086d

File tree

6 files changed

+204
-46
lines changed

6 files changed

+204
-46
lines changed

cli/lsp/config.rs

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -425,12 +425,19 @@ pub struct Settings {
425425
pub workspace: WorkspaceSettings,
426426
}
427427

428+
#[derive(Debug)]
429+
struct WithCanonicalizedSpecifier<T> {
430+
/// Stored canonicalized specifier, which is used for file watcher events.
431+
canonicalized_specifier: ModuleSpecifier,
432+
file: T,
433+
}
434+
428435
/// Contains the config file and dependent information.
429436
#[derive(Debug)]
430437
struct LspConfigFileInfo {
431-
config_file: ConfigFile,
438+
config_file: WithCanonicalizedSpecifier<ConfigFile>,
432439
/// An optional deno.lock file, which is resolved relative to the config file.
433-
maybe_lockfile: Option<Arc<Mutex<Lockfile>>>,
440+
maybe_lockfile: Option<WithCanonicalizedSpecifier<Arc<Mutex<Lockfile>>>>,
434441
/// The canonicalized node_modules directory, which is found relative to the config file.
435442
maybe_node_modules_dir: Option<PathBuf>,
436443
excluded_paths: Vec<Url>,
@@ -469,14 +476,42 @@ impl Config {
469476
}
470477

471478
pub fn maybe_config_file(&self) -> Option<&ConfigFile> {
472-
self.maybe_config_file_info.as_ref().map(|c| &c.config_file)
479+
self
480+
.maybe_config_file_info
481+
.as_ref()
482+
.map(|c| &c.config_file.file)
483+
}
484+
485+
/// Canonicalized specifier of the config file, which should only be used for
486+
/// file watcher events. Otherwise, prefer using the non-canonicalized path
487+
/// as the rest of the CLI does for config files.
488+
pub fn maybe_config_file_canonicalized_specifier(
489+
&self,
490+
) -> Option<&ModuleSpecifier> {
491+
self
492+
.maybe_config_file_info
493+
.as_ref()
494+
.map(|c| &c.config_file.canonicalized_specifier)
473495
}
474496

475497
pub fn maybe_lockfile(&self) -> Option<&Arc<Mutex<Lockfile>>> {
476498
self
477499
.maybe_config_file_info
478500
.as_ref()
479-
.and_then(|c| c.maybe_lockfile.as_ref())
501+
.and_then(|c| c.maybe_lockfile.as_ref().map(|l| &l.file))
502+
}
503+
504+
/// Canonicalized specifier of the lockfile, which should only be used for
505+
/// file watcher events. Otherwise, prefer using the non-canonicalized path
506+
/// as the rest of the CLI does for config files.
507+
pub fn maybe_lockfile_canonicalized_specifier(
508+
&self,
509+
) -> Option<&ModuleSpecifier> {
510+
self.maybe_config_file_info.as_ref().and_then(|c| {
511+
c.maybe_lockfile
512+
.as_ref()
513+
.map(|l| &l.canonicalized_specifier)
514+
})
480515
}
481516

482517
pub fn clear_config_file(&mut self) {
@@ -485,7 +520,17 @@ impl Config {
485520

486521
pub fn set_config_file(&mut self, config_file: ConfigFile) {
487522
self.maybe_config_file_info = Some(LspConfigFileInfo {
488-
maybe_lockfile: resolve_lockfile_from_config(&config_file),
523+
maybe_lockfile: resolve_lockfile_from_config(&config_file).map(
524+
|lockfile| {
525+
let path = canonicalize_path_maybe_not_exists(&lockfile.filename)
526+
.unwrap_or_else(|_| lockfile.filename.clone());
527+
WithCanonicalizedSpecifier {
528+
canonicalized_specifier: ModuleSpecifier::from_file_path(path)
529+
.unwrap(),
530+
file: Arc::new(Mutex::new(lockfile)),
531+
}
532+
},
533+
),
489534
maybe_node_modules_dir: resolve_node_modules_dir(&config_file),
490535
excluded_paths: config_file
491536
.to_files_config()
@@ -496,7 +541,16 @@ impl Config {
496541
.into_iter()
497542
.filter_map(|path| ModuleSpecifier::from_file_path(path).ok())
498543
.collect(),
499-
config_file,
544+
config_file: WithCanonicalizedSpecifier {
545+
canonicalized_specifier: config_file
546+
.specifier
547+
.to_file_path()
548+
.ok()
549+
.and_then(|p| canonicalize_path_maybe_not_exists(&p).ok())
550+
.and_then(|p| ModuleSpecifier::from_file_path(p).ok())
551+
.unwrap_or_else(|| config_file.specifier.clone()),
552+
file: config_file,
553+
},
500554
});
501555
}
502556

@@ -739,9 +793,7 @@ fn specifier_enabled(
739793
.unwrap_or_else(|| settings.workspace.enable)
740794
}
741795

742-
fn resolve_lockfile_from_config(
743-
config_file: &ConfigFile,
744-
) -> Option<Arc<Mutex<Lockfile>>> {
796+
fn resolve_lockfile_from_config(config_file: &ConfigFile) -> Option<Lockfile> {
745797
let lockfile_path = match config_file.resolve_lockfile_path() {
746798
Ok(Some(value)) => value,
747799
Ok(None) => return None,
@@ -769,15 +821,13 @@ fn resolve_node_modules_dir(config_file: &ConfigFile) -> Option<PathBuf> {
769821
canonicalize_path_maybe_not_exists(&node_modules_dir).ok()
770822
}
771823

772-
fn resolve_lockfile_from_path(
773-
lockfile_path: PathBuf,
774-
) -> Option<Arc<Mutex<Lockfile>>> {
824+
fn resolve_lockfile_from_path(lockfile_path: PathBuf) -> Option<Lockfile> {
775825
match Lockfile::new(lockfile_path, false) {
776826
Ok(value) => {
777827
if let Ok(specifier) = ModuleSpecifier::from_file_path(&value.filename) {
778828
lsp_log!(" Resolved lock file: \"{}\"", specifier);
779829
}
780-
Some(Arc::new(Mutex::new(value)))
830+
Some(value)
781831
}
782832
Err(err) => {
783833
lsp_warn!("Error loading lockfile: {:#}", err);

cli/lsp/language_server.rs

Lines changed: 50 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1458,18 +1458,8 @@ impl Inner {
14581458
&mut self,
14591459
params: DidChangeWatchedFilesParams,
14601460
) {
1461-
fn has_lockfile_changed(
1462-
lockfile: &Lockfile,
1463-
changed_urls: &HashSet<Url>,
1464-
) -> bool {
1465-
let lockfile_path = lockfile.filename.clone();
1466-
let Ok(specifier) = ModuleSpecifier::from_file_path(&lockfile_path) else {
1467-
return false;
1468-
};
1469-
if !changed_urls.contains(&specifier) {
1470-
return false;
1471-
}
1472-
match Lockfile::new(lockfile_path, false) {
1461+
fn has_lockfile_content_changed(lockfile: &Lockfile) -> bool {
1462+
match Lockfile::new(lockfile.filename.clone(), false) {
14731463
Ok(new_lockfile) => {
14741464
// only update if the lockfile has changed
14751465
FastInsecureHasher::hash(lockfile)
@@ -1482,6 +1472,53 @@ impl Inner {
14821472
}
14831473
}
14841474

1475+
fn has_config_changed(config: &Config, changes: &HashSet<Url>) -> bool {
1476+
// Check the canonicalized specifier here because file watcher
1477+
// changes will be for the canonicalized path in vscode, but also check the
1478+
// non-canonicalized specifier in order to please the tests and handle
1479+
// a client that might send that instead.
1480+
if config
1481+
.maybe_config_file_canonicalized_specifier()
1482+
.map(|s| changes.contains(s))
1483+
.unwrap_or(false)
1484+
{
1485+
return true;
1486+
}
1487+
match config.maybe_config_file() {
1488+
Some(file) => {
1489+
if changes.contains(&file.specifier) {
1490+
return true;
1491+
}
1492+
}
1493+
None => {
1494+
// check for auto-discovery
1495+
if changes.iter().any(|url| {
1496+
url.path().ends_with("/deno.json")
1497+
|| url.path().ends_with("/deno.jsonc")
1498+
}) {
1499+
return true;
1500+
}
1501+
}
1502+
}
1503+
1504+
// if the lockfile has changed, reload the config as well
1505+
if let Some(lockfile) = config.maybe_lockfile() {
1506+
let lockfile_matches = config
1507+
.maybe_lockfile_canonicalized_specifier()
1508+
.map(|s| changes.contains(s))
1509+
.or_else(|| {
1510+
ModuleSpecifier::from_file_path(&lockfile.lock().filename)
1511+
.ok()
1512+
.map(|s| changes.contains(&s))
1513+
})
1514+
.unwrap_or(false);
1515+
lockfile_matches && has_lockfile_content_changed(&lockfile.lock())
1516+
} else {
1517+
// check for auto-discovery
1518+
changes.iter().any(|url| url.path().ends_with("/deno.lock"))
1519+
}
1520+
}
1521+
14851522
let mark = self
14861523
.performance
14871524
.mark("did_change_watched_files", Some(&params));
@@ -1493,23 +1530,7 @@ impl Inner {
14931530
.collect();
14941531

14951532
// if the current deno.json has changed, we need to reload it
1496-
let has_config_changed = match self.config.maybe_config_file() {
1497-
Some(config_file) => changes.contains(&config_file.specifier),
1498-
None => {
1499-
// check for auto-discovery
1500-
changes.iter().any(|url| {
1501-
url.path().ends_with("/deno.json")
1502-
|| url.path().ends_with("/deno.jsonc")
1503-
})
1504-
}
1505-
} || match self.config.maybe_lockfile() {
1506-
Some(lockfile) => has_lockfile_changed(&lockfile.lock(), &changes),
1507-
None => {
1508-
// check for auto-discovery
1509-
changes.iter().any(|url| url.path().ends_with("/deno.lock"))
1510-
}
1511-
};
1512-
if has_config_changed {
1533+
if has_config_changed(&self.config, &changes) {
15131534
if let Err(err) = self.update_config_file().await {
15141535
self.client.show_message(MessageType::WARNING, err);
15151536
}

cli/tests/integration/lsp_tests.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,72 @@ fn lsp_import_map_config_file_auto_discovered() {
544544
client.shutdown();
545545
}
546546

547+
#[test]
548+
fn lsp_import_map_config_file_auto_discovered_symlink() {
549+
let context = TestContextBuilder::new()
550+
// DO NOT COPY THIS CODE. Very rare case where we want to force the temp
551+
// directory on the CI to not be a symlinked directory because we are
552+
// testing a scenario with a symlink to a non-symlink in the same directory
553+
// tree. Generally you want to ensure your code works in symlinked directories
554+
// so don't use this unless you have a similar scenario.
555+
.temp_dir_path(std::env::temp_dir().canonicalize().unwrap())
556+
.use_temp_cwd()
557+
.build();
558+
let temp_dir = context.temp_dir();
559+
temp_dir.create_dir_all("lib");
560+
temp_dir.write("lib/b.ts", r#"export const b = "b";"#);
561+
562+
let mut client = context.new_lsp_command().capture_stderr().build();
563+
client.initialize_default();
564+
565+
// now create a symlink in the current directory to a subdir/deno.json
566+
// and ensure the watched files notification still works
567+
temp_dir.create_dir_all("subdir");
568+
temp_dir.write("subdir/deno.json", r#"{ }"#);
569+
temp_dir.symlink_file(
570+
temp_dir.path().join("subdir").join("deno.json"),
571+
temp_dir.path().join("deno.json"),
572+
);
573+
client.did_change_watched_files(json!({
574+
"changes": [{
575+
// the client will give a watched file changed event for the symlink's target
576+
"uri": temp_dir.path().join("subdir/deno.json").canonicalize().uri(),
577+
"type": 2
578+
}]
579+
}));
580+
581+
// this will discover the deno.json in the root
582+
let search_line = format!(
583+
"Auto-resolved configuration file: \"{}\"",
584+
temp_dir.uri().join("deno.json").unwrap().as_str()
585+
);
586+
client.wait_until_stderr_line(|line| line.contains(&search_line));
587+
588+
// now open a file which will cause a diagnostic because the import map is empty
589+
let diagnostics = client.did_open(json!({
590+
"textDocument": {
591+
"uri": temp_dir.uri().join("a.ts").unwrap(),
592+
"languageId": "typescript",
593+
"version": 1,
594+
"text": "import { b } from \"/~/b.ts\";\n\nconsole.log(b);\n"
595+
}
596+
}));
597+
assert_eq!(diagnostics.all().len(), 1);
598+
599+
// update the import map to have the imports now
600+
temp_dir.write("subdir/deno.json", r#"{ "imports": { "/~/": "./lib/" } }"#);
601+
client.did_change_watched_files(json!({
602+
"changes": [{
603+
// now still say that the target path has changed
604+
"uri": temp_dir.path().join("subdir/deno.json").canonicalize().uri(),
605+
"type": 2
606+
}]
607+
}));
608+
assert_eq!(client.read_diagnostics().all().len(), 0);
609+
610+
client.shutdown();
611+
}
612+
547613
#[test]
548614
fn lsp_deno_task() {
549615
let context = TestContextBuilder::new().use_temp_cwd().build();

test_util/src/builders.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ use crate::env_vars_for_npm_tests_no_sync_download;
1919
use crate::fs::PathRef;
2020
use crate::http_server;
2121
use crate::lsp::LspClientBuilder;
22-
use crate::new_deno_dir;
2322
use crate::pty::Pty;
2423
use crate::strip_ansi_codes;
2524
use crate::testdata_path;
@@ -36,6 +35,7 @@ pub struct TestContextBuilder {
3635
/// to the temp folder and runs the test from there. This is useful when
3736
/// the test creates files in the testdata directory (ex. a node_modules folder)
3837
copy_temp_dir: Option<String>,
38+
temp_dir_path: Option<PathBuf>,
3939
cwd: Option<String>,
4040
envs: HashMap<String, String>,
4141
deno_exe: Option<PathRef>,
@@ -50,6 +50,11 @@ impl TestContextBuilder {
5050
Self::new().use_http_server().add_npm_env_vars()
5151
}
5252

53+
pub fn temp_dir_path(mut self, path: impl AsRef<Path>) -> Self {
54+
self.temp_dir_path = Some(path.as_ref().to_path_buf());
55+
self
56+
}
57+
5358
pub fn use_http_server(mut self) -> Self {
5459
self.use_http_server = true;
5560
self
@@ -117,9 +122,13 @@ impl TestContextBuilder {
117122
}
118123

119124
pub fn build(&self) -> TestContext {
120-
let deno_dir = new_deno_dir(); // keep this alive for the test
125+
let temp_dir_path = self
126+
.temp_dir_path
127+
.clone()
128+
.unwrap_or_else(std::env::temp_dir);
129+
let deno_dir = TempDir::new_in(&temp_dir_path);
121130
let temp_dir = if self.use_separate_deno_dir {
122-
TempDir::new()
131+
TempDir::new_in(&temp_dir_path)
123132
} else {
124133
deno_dir.clone()
125134
};

test_util/src/fs.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,10 @@ impl TempDir {
324324
Self::new_inner(&std::env::temp_dir(), Some(prefix))
325325
}
326326

327+
pub fn new_in(parent_dir: &Path) -> Self {
328+
Self::new_inner(parent_dir, None)
329+
}
330+
327331
pub fn new_with_path(path: &Path) -> Self {
328332
Self(Arc::new(TempDirInner::Path(PathRef(path.to_path_buf()))))
329333
}

test_util/src/lsp.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -641,16 +641,24 @@ impl LspClient {
641641
.stderr_lines_rx
642642
.as_ref()
643643
.expect("must setup with client_builder.capture_stderr()");
644+
let mut found_lines = Vec::new();
644645
while Instant::now() < timeout_time {
645646
if let Ok(line) = lines_rx.try_recv() {
646647
if condition(&line) {
647648
return;
648649
}
650+
found_lines.push(line);
649651
}
650652
std::thread::sleep(Duration::from_millis(20));
651653
}
652654

653-
panic!("Timed out.")
655+
eprintln!("==== STDERR OUTPUT ====");
656+
for line in found_lines {
657+
eprintln!("{}", line)
658+
}
659+
eprintln!("== END STDERR OUTPUT ==");
660+
661+
panic!("Timed out waiting on condition.")
654662
}
655663

656664
pub fn initialize_default(&mut self) {

0 commit comments

Comments
 (0)