Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
12 changes: 12 additions & 0 deletions lsp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,18 @@
"verbose"
]
},
"python.analysis.diagnosticMode": {
"type": "string",
"description": "Controls which files are analyzed for diagnostics. 'openFilesOnly' (default) shows type errors only in open files. 'workspace' shows type errors in all files within the workspace.",
"default": "openFilesOnly",
"enum": [
"openFilesOnly",
"workspace"
],
"enumDescriptions": [
"Show diagnostics only for files that are currently open in the editor",
"Show diagnostics for all files in the workspace, similar to Pyright's workspace mode"
]
"python.pyrefly.analysis.disabledLanguageServices": {
"type": "object",
"default": {},
Expand Down
3 changes: 2 additions & 1 deletion lsp/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,8 @@ export async function activate(context: ExtensionContext) {

context.subscriptions.push(
workspace.onDidChangeConfiguration(async event => {
if (event.affectsConfiguration('python.pyrefly')) {
if (event.affectsConfiguration('python.pyrefly') ||
event.affectsConfiguration('python.analysis')) {
client.sendNotification(DidChangeConfigurationNotification.type, {
settings: {},
});
Expand Down
108 changes: 99 additions & 9 deletions pyrefly/lib/lsp/non_wasm/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ use crate::lsp::non_wasm::queue::HeavyTaskQueue;
use crate::lsp::non_wasm::queue::LspEvent;
use crate::lsp::non_wasm::queue::LspQueue;
use crate::lsp::non_wasm::transaction_manager::TransactionManager;
use crate::lsp::non_wasm::workspace::DiagnosticMode;
use crate::lsp::non_wasm::workspace::LspAnalysisConfig;
use crate::lsp::non_wasm::workspace::Workspace;
use crate::lsp::non_wasm::workspace::Workspaces;
Expand Down Expand Up @@ -923,6 +924,7 @@ impl Server {
self.validate_in_memory_and_commit_if_possible(ide_transaction_manager);
let transaction =
ide_transaction_manager.non_committable_transaction(&self.state);

self.send_response(new_response(
x.id,
Ok(self.document_diagnostics(&transaction, params)),
Expand Down Expand Up @@ -1121,9 +1123,24 @@ impl Server {
.state
.config_finder()
.python_file(ModuleName::unknown(), e.path());
if open_files.contains_key(&path)
&& config.project_includes.covers(&path)
&& !config.project_excludes.covers(&path)

// Get diagnostic mode for this file's workspace
let diagnostic_mode = self.workspaces.get_diagnostic_mode(&path);

// File must be in project (not excluded) to show diagnostics
let is_in_project =
config.project_includes.covers(&path) && !config.project_excludes.covers(&path);

// Then check based on diagnostic mode
let is_open = open_files.contains_key(&path);
let should_show = match diagnostic_mode {
// Workspace mode: show if in project (open or closed files)
DiagnosticMode::Workspace => is_in_project,
// OpenFilesOnly mode: show if open AND in project
DiagnosticMode::OpenFilesOnly => is_open && is_in_project,
};

if should_show
&& self
.type_error_display_status(e.path().as_path())
.is_enabled()
Expand Down Expand Up @@ -1215,19 +1232,84 @@ impl Server {
let handles =
Self::validate_in_memory_for_transaction(&self.state, &self.open_files, transaction);

// Check if any workspace is in workspace diagnostic mode
let has_workspace_mode = self.workspaces.roots().iter().any(|root| {
matches!(
self.workspaces.get_diagnostic_mode(root),
DiagnosticMode::Workspace
)
});

// In workspace mode, analyze all project files so get_all_errors() includes unopened files
if has_workspace_mode {
let open_file_paths: std::collections::HashSet<_> =
self.open_files.read().keys().cloned().collect();
if let Some(first_open_file) = open_file_paths.iter().next() {
let module_path = ModulePath::filesystem(first_open_file.clone());
let config = self
.state
.config_finder()
.python_file(ModuleName::unknown(), &module_path);
let project_path_blobs = config.get_filtered_globs(None);
if let Ok(paths) = project_path_blobs.files() {
let project_handles: Vec<_> = paths
.into_iter()
.filter_map(|path| {
// Skip files that are already open (already in handles)
if open_file_paths.contains(&path) {
return None;
}
let module_path = ModulePath::filesystem(path.clone());
let path_config = self
.state
.config_finder()
.python_file(ModuleName::unknown(), &module_path);
if config == path_config {
Some(handle_from_module_path(&self.state, module_path))
} else {
None
}
})
.collect();
// Analyze only for errors, not full indexing
transaction.run(&project_handles, Require::Errors);
}
}
}

let publish = |transaction: &Transaction| {
let mut diags: SmallMap<PathBuf, Vec<Diagnostic>> = SmallMap::new();
let open_files = self.open_files.read();

// Pre-populate with empty arrays for all open files to ensure we send
// publishDiagnostics notifications even when errors are cleared
for x in open_files.keys() {
diags.insert(x.as_path().to_owned(), Vec::new());
}
for e in transaction.get_errors(&handles).collect_errors().shown {

// In workspace mode, use get_all_errors() to get errors from all project files.
// In open-files-only mode, use get_errors(&handles) to only get errors from open files.
// The filtering by diagnostic mode and project includes/excludes is handled in get_diag_if_shown.
let errors = if has_workspace_mode {
transaction.get_all_errors()
} else {
transaction.get_errors(&handles)
};

for e in errors.collect_errors().shown {
if let Some((path, diag)) = self.get_diag_if_shown(&e, &open_files) {
diags.entry(path.to_owned()).or_default().push(diag);
}
}

for (path, diagnostics) in diags.iter_mut() {
let handle = make_open_handle(&self.state, path);
// Use appropriate handle type: memory handle for open files, filesystem for others
let is_open = open_files.contains_key(path);
let handle = if is_open {
make_open_handle(&self.state, path)
} else {
handle_from_module_path(&self.state, ModulePath::filesystem(path.clone()))
};
Self::append_unreachable_diagnostics(transaction, &handle, diagnostics);
}
self.connection.publish_diagnostics(diags);
Expand Down Expand Up @@ -2256,10 +2338,17 @@ impl Server {
transaction: &Transaction<'_>,
params: DocumentDiagnosticParams,
) -> DocumentDiagnosticReport {
let handle = make_open_handle(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually disagree with this part. Language clients will not send document_diagnostics for every file. If they request it, we should return it regardless of the mode.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm yess

&self.state,
&params.text_document.uri.to_file_path().unwrap(),
);
let file_path = params.text_document.uri.to_file_path().unwrap();
let is_file_open = self.open_files.read().contains_key(&file_path);

// When diagnostics are explicitly requested, always return them regardless of mode
let handle = if is_file_open {
make_open_handle(&self.state, &file_path)
} else {
let module_path = ModulePath::filesystem(file_path.clone());
handle_from_module_path(&self.state, module_path)
};

let mut items = Vec::new();
let open_files = &self.open_files.read();
for e in transaction.get_errors(once(&handle)).collect_errors().shown {
Expand All @@ -2268,6 +2357,7 @@ impl Server {
}
}
Self::append_unreachable_diagnostics(transaction, &handle, &mut items);

DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
full_document_diagnostic_report: FullDocumentDiagnosticReport {
items,
Expand Down
12 changes: 11 additions & 1 deletion pyrefly/lib/lsp/non_wasm/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,6 @@ impl DisabledLanguageServices {
#[derive(Clone, Copy, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LspAnalysisConfig {
#[allow(dead_code)]
pub diagnostic_mode: Option<DiagnosticMode>,
pub import_format: Option<ImportFormat>,
pub inlay_hints: Option<InlayHintConfig>,
Expand Down Expand Up @@ -464,6 +463,17 @@ impl Workspaces {
}
}
}

/// Get the diagnostic mode for a given path.
/// Returns the configured diagnostic mode for the workspace containing the path,
/// or `OpenFilesOnly` as the default if not configured.
pub fn get_diagnostic_mode(&self, path: &std::path::Path) -> DiagnosticMode {
self.get_with(path.to_path_buf(), |(_, w)| {
w.lsp_analysis_config
.and_then(|config| config.diagnostic_mode)
.unwrap_or(DiagnosticMode::OpenFilesOnly)
})
}
}

#[cfg(test)]
Expand Down
1 change: 1 addition & 0 deletions pyrefly/lib/test/lsp/lsp_interaction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ mod rename;
mod type_definition;
mod util;
mod will_rename_files;
mod workspace_diagnostic_mode;
mod workspace_symbol;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# File with intentional type error for testing workspace diagnostic mode

def add_numbers(x: int, y: int) -> int:
return x + y

# This should cause a type error: passing string to int parameter
result = add_numbers("hello", "world")
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# File that will be opened in tests (should always show diagnostics)

def greet(name: str) -> str:
return f"Hello, {name}"

# Type error: passing int to str parameter
message = greet(123)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Pyrefly config for workspace diagnostic mode tests
project-includes = ["**/*.py"]
147 changes: 147 additions & 0 deletions pyrefly/lib/test/lsp/lsp_interaction/workspace_diagnostic_mode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

use lsp_types::Url;

use crate::test::lsp::lsp_interaction::object_model::InitializeSettings;
use crate::test::lsp::lsp_interaction::object_model::LspInteraction;
use crate::test::lsp::lsp_interaction::util::get_test_files_root;

/// Test that workspace mode shows errors from unopened files via publishDiagnostics
#[test]
fn test_workspace_mode_shows_unopened_file_errors() {
let test_files_root = get_test_files_root();
let root_path = test_files_root.path().join("workspace_diagnostic_mode");
let scope_uri = Url::from_file_path(&root_path).unwrap();
let mut interaction = LspInteraction::new();
interaction.set_root(root_path.clone());
interaction.initialize(InitializeSettings {
workspace_folders: Some(vec![(
"workspace_diagnostic_mode".to_owned(),
scope_uri.clone(),
)]),
configuration: Some(Some(serde_json::json!([{
"pyrefly": {"displayTypeErrors": "force-on"},
"analysis": {"diagnosticMode": "workspace"}
}]))),
..Default::default()
});

// Open file_with_error.py (has 2 errors)
// In workspace mode, this should trigger publishDiagnostics for ALL workspace files with errors
interaction.server.did_open("file_with_error.py");

// The opened file should show its 2 errors
interaction
.client
.expect_publish_diagnostics_error_count(root_path.join("file_with_error.py"), 2);

// The UNOPENED file (opened_file.py) should ALSO show its 1 error via publishDiagnostics
// This is the key test - workspace mode publishes diagnostics for unopened files!
interaction
.client
.expect_publish_diagnostics_error_count(root_path.join("opened_file.py"), 1);

interaction.shutdown();
}

/// Test that openFilesOnly mode only publishes diagnostics for open files
#[test]
fn test_open_files_only_mode_filters_correctly() {
let test_files_root = get_test_files_root();
let root_path = test_files_root.path().join("workspace_diagnostic_mode");
let scope_uri = Url::from_file_path(&root_path).unwrap();
let mut interaction = LspInteraction::new();
interaction.set_root(root_path.clone());
interaction.initialize(InitializeSettings {
workspace_folders: Some(vec![(
"workspace_diagnostic_mode".to_owned(),
scope_uri.clone(),
)]),
configuration: Some(Some(serde_json::json!([{
"pyrefly": {"displayTypeErrors": "force-on"},
"analysis": {"diagnosticMode": "openFilesOnly"}
}]))),
..Default::default()
});

// Open file with errors
interaction.server.did_open("file_with_error.py");

// Should show errors for the opened file
interaction
.client
.expect_publish_diagnostics_error_count(root_path.join("file_with_error.py"), 2);

interaction.shutdown();
}

/// Test default behavior (should be openFilesOnly)
#[test]
fn test_default_mode_is_open_files_only() {
let test_files_root = get_test_files_root();
let root_path = test_files_root.path().join("workspace_diagnostic_mode");
let scope_uri = Url::from_file_path(&root_path).unwrap();
let mut interaction = LspInteraction::new();
interaction.set_root(root_path.clone());
interaction.initialize(InitializeSettings {
workspace_folders: Some(vec![(
"workspace_diagnostic_mode".to_owned(),
scope_uri.clone(),
)]),
configuration: Some(Some(
serde_json::json!([{"pyrefly": {"displayTypeErrors": "force-on"}}]),
)),
..Default::default()
});

// Open file with errors
interaction.server.did_open("file_with_error.py");

// Default mode is openFilesOnly, should show errors for opened file only
interaction
.client
.expect_publish_diagnostics_error_count(root_path.join("file_with_error.py"), 2);

interaction.shutdown();
}

/// Test that workspace mode filters out errors from stdlib/dependencies
#[test]
fn test_workspace_mode_excludes_files_outside_workspace() {
let test_files_root = get_test_files_root();
let root_path = test_files_root.path().join("workspace_diagnostic_mode");
let scope_uri = Url::from_file_path(&root_path).unwrap();
let mut interaction = LspInteraction::new();
interaction.set_root(root_path.clone());
interaction.initialize(InitializeSettings {
workspace_folders: Some(vec![(
"workspace_diagnostic_mode".to_owned(),
scope_uri.clone(),
)]),
configuration: Some(Some(serde_json::json!([{
"pyrefly": {"displayTypeErrors": "force-on"},
"analysis": {"diagnosticMode": "workspace"}
}]))),
..Default::default()
});

// Open file with errors
interaction.server.did_open("file_with_error.py");

// Should show errors for workspace files only, not stdlib/dependencies
interaction
.client
.expect_publish_diagnostics_error_count(root_path.join("file_with_error.py"), 2);

// Unopened file errors should also be published
interaction
.client
.expect_publish_diagnostics_error_count(root_path.join("opened_file.py"), 1);

interaction.shutdown();
}