Skip to content
Open
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
12 changes: 12 additions & 0 deletions lsp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,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 @@ -320,7 +320,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
177 changes: 148 additions & 29 deletions pyrefly/lib/lsp/non_wasm/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ use crate::lsp::non_wasm::stdlib::is_python_stdlib_file;
use crate::lsp::non_wasm::stdlib::should_show_stdlib_error;
use crate::lsp::non_wasm::transaction_manager::TransactionManager;
use crate::lsp::non_wasm::will_rename_files::will_rename_files;
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 @@ -1033,6 +1034,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 @@ -1202,15 +1204,88 @@ impl Server {

/// Run the transaction with the in-memory content of open files. Returns the handles of open files when the transaction is done.
fn validate_in_memory_for_transaction(
&self,
state: &State,
open_files: &RwLock<HashMap<PathBuf, Arc<LspFile>>>,
transaction: &mut Transaction<'_>,
) -> Vec<Handle> {
let handles = open_files
.read()
.keys()
.map(|x| make_open_handle(state, x))
.collect::<Vec<_>>();
Self::validate_in_memory_for_transaction_with_workspaces(
Some(&self.workspaces),
state,
open_files,
transaction,
)
}

/// Helper that can work with or without workspaces (for background tasks)
fn validate_in_memory_for_transaction_with_workspaces(
workspaces: Option<&Arc<Workspaces>>,
state: &State,
open_files: &RwLock<HashMap<PathBuf, Arc<LspFile>>>,
transaction: &mut Transaction<'_>,
) -> Vec<Handle> {
let open_file_list: Vec<_> = open_files.read().keys().cloned().collect();

// Pass 1: Collect which open files have workspace mode enabled (if workspaces available)
let workspace_mode_per_file: Vec<(PathBuf, bool)> = if let Some(workspaces) = workspaces {
open_file_list
.iter()
.map(|path| {
let has_workspace_mode =
workspaces.get_with(path.clone(), |(_, workspace)| {
workspace
.lsp_analysis_config
.and_then(|config| config.diagnostic_mode)
.map_or(false, |mode| matches!(mode, DiagnosticMode::Workspace))
});
(path.clone(), has_workspace_mode)
})
.collect()
} else {
// No workspaces means openFilesOnly mode for all
open_file_list.iter().map(|p| (p.clone(), false)).collect()
};

// Pass 2: Gather handles based on workspace mode
let mut handles = Vec::new();
let mut processed_configs: std::collections::HashSet<ArcId<ConfigFile>> =
std::collections::HashSet::new();

for (path, has_workspace_mode) in workspace_mode_per_file {
// Always add the open file handle
handles.push(make_open_handle(state, &path));

// If workspace mode enabled for this file, add all project files from its config
if has_workspace_mode {
let module_path = ModulePath::filesystem(path.clone());
let config = state
.config_finder()
.python_file(ModuleName::unknown(), &module_path);

// Only process each unique config once
if processed_configs.insert(config.dupe()) {
if let Ok(paths) = config.get_filtered_globs(None).files() {
for project_path in paths {
// Skip files that are already open
if open_file_list.contains(&project_path) {
continue;
}

let project_module_path = ModulePath::filesystem(project_path.clone());
let path_config = state
.config_finder()
.python_file(ModuleName::unknown(), &project_module_path);

// Only include files from the same config
if config == path_config {
handles.push(handle_from_module_path(state, project_module_path));
}
}
}
}
}
}

transaction.set_memory(
open_files
.read()
Expand Down Expand Up @@ -1238,31 +1313,46 @@ impl Server {

let type_error_status = self.type_error_display_status(e.path().as_path());

// Check stdlib filtering first
let should_show_stdlib_error =
should_show_stdlib_error(&config, type_error_status, &path);

if is_python_stdlib_file(&path) && !should_show_stdlib_error {
return None;
}

if let Some(lsp_file) = open_files.get(&path)
&& config.project_includes.covers(&path)
&& !config.project_excludes.covers(&path)
&& type_error_status.is_enabled()
{
return match &**lsp_file {
LspFile::Notebook(notebook) => {
let error_cell = e.get_notebook_cell()?;
let error_cell_uri = notebook.get_cell_url(error_cell)?;
if let Some(filter_cell) = cell_uri
&& error_cell_uri != filter_cell
{
None
} else {
Some((PathBuf::from(error_cell_uri.to_string()), e.to_diagnostic()))
// 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);

// Check based on diagnostic mode
let is_open = open_files.contains_key(&path);
let should_show = match diagnostic_mode {
DiagnosticMode::Workspace => is_in_project,
DiagnosticMode::OpenFilesOnly => is_open && is_in_project,
};

if should_show && type_error_status.is_enabled() {
return if let Some(lsp_file) = open_files.get(&path) {
match &**lsp_file {
LspFile::Notebook(notebook) => {
let error_cell = e.get_notebook_cell()?;
let error_cell_uri = notebook.get_cell_url(error_cell)?;
if let Some(filter_cell) = cell_uri
&& error_cell_uri != filter_cell
{
None
} else {
Some((PathBuf::from(error_cell_uri.to_string()), e.to_diagnostic()))
}
}
LspFile::Source(_) => Some((path.to_path_buf(), e.to_diagnostic())),
}
LspFile::Source(_) => Some((path.to_path_buf(), e.to_diagnostic())),
} else {
// File not open but in workspace mode - still show diagnostic
Some((path.to_path_buf(), e.to_diagnostic()))
};
}
}
Expand Down Expand Up @@ -1352,32 +1442,48 @@ impl Server {
Err(transaction) => transaction,
};
let handles =
Self::validate_in_memory_for_transaction(&self.state, &self.open_files, transaction);
self.validate_in_memory_for_transaction(&self.state, &self.open_files, transaction);

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

// Pre-populate notebook cells
for x in open_notebook_cells.keys() {
diags.insert(PathBuf::from(x.to_string()), Vec::new());
notebook_cell_urls.insert(PathBuf::from(x.to_string()), x.clone());
}

// Pre-populate regular files (non-notebooks)
for (x, file) in open_files.iter() {
if !file.is_notebook() {
diags.insert(x.as_path().to_owned(), Vec::new());
}
}

// handles already contains all files we need (open files + project files for workspace mode)
// The filtering by diagnostic mode and project includes/excludes is handled in get_diag_if_shown.
for e in transaction.get_errors(&handles).collect_errors().shown {
if let Some((path, diag)) = self.get_diag_if_shown(&e, &open_files, None) {
diags.entry(path.to_owned()).or_default().push(diag);
}
}

for (path, diagnostics) in diags.iter_mut() {
// Skip notebook cells - they're handled separately
if notebook_cell_urls.contains_key(path) {
continue;
}
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::append_unused_parameter_diagnostics(transaction, &handle, diagnostics);
}
Expand Down Expand Up @@ -1509,11 +1615,12 @@ impl Server {
let lsp_queue = self.lsp_queue.dupe();
let cancellation_handles = self.cancellation_handles.dupe();
let open_files = self.open_files.dupe();
let workspaces = self.workspaces.dupe();
self.recheck_queue.queue_task(Box::new(move || {
let mut transaction = state.new_committable_transaction(Require::indexing(), None);
f(transaction.as_mut());

Self::validate_in_memory_for_transaction(&state, &open_files, transaction.as_mut());
Self::validate_in_memory_for_transaction_with_workspaces(Some(&workspaces), &state, &open_files, transaction.as_mut());

// Commit will be blocked until there are no ongoing reads.
// If we have some long running read jobs that can be cancelled, we should cancel them
Expand Down Expand Up @@ -1879,14 +1986,15 @@ impl Server {
let open_files = self.open_files.dupe();
let sourcedb_queue = self.sourcedb_queue.dupe();
let invalidated_configs = self.invalidated_configs.dupe();
let workspaces = self.workspaces.dupe();
self.recheck_queue.queue_task(Box::new(move || {
// Clear out the memory associated with this file.
// Not a race condition because we immediately call validate_in_memory to put back the open files as they are now.
// Having the extra file hanging around doesn't harm anything, but does use extra memory.
let mut transaction = state.new_committable_transaction(Require::indexing(), None);
transaction.as_mut().set_memory(vec![(uri, None)]);
let _ =
Self::validate_in_memory_for_transaction(&state, &open_files, transaction.as_mut());
Self::validate_in_memory_for_transaction_with_workspaces(Some(&workspaces), &state, &open_files, transaction.as_mut());
state.commit_transaction(transaction);
queue_source_db_rebuild_and_recheck(
state.dupe(),
Expand Down Expand Up @@ -2194,14 +2302,15 @@ impl Server {
let state = self.state.dupe();
let open_files = self.open_files.dupe();
let cancellation_handles = self.cancellation_handles.dupe();
let workspaces = self.workspaces.dupe();

let connection = self.connection.dupe();
self.find_reference_queue.queue_task(Box::new(move || {
let mut transaction = state.cancellable_transaction();
cancellation_handles
.lock()
.insert(request_id.clone(), transaction.get_cancellation_handle());
Self::validate_in_memory_for_transaction(&state, &open_files, transaction.as_mut());
Self::validate_in_memory_for_transaction_with_workspaces(Some(&workspaces), &state, &open_files, transaction.as_mut());
match transaction.find_global_references_from_definition(
handle.sys_info(),
metadata,
Expand Down Expand Up @@ -2607,7 +2716,16 @@ impl Server {
} else {
uri.to_file_path().unwrap()
};
let handle = make_open_handle(&self.state, &path);

// Check if file is open to determine handle type
let is_file_open = self.open_files.read().contains_key(&path);
let handle = if is_file_open {
make_open_handle(&self.state, &path)
} else {
let module_path = ModulePath::filesystem(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 Down Expand Up @@ -2753,11 +2871,12 @@ impl Server {
let lsp_queue = self.lsp_queue.dupe();
let cancellation_handles = self.cancellation_handles.dupe();
let open_files = self.open_files.dupe();
let workspaces = self.workspaces.dupe();
self.recheck_queue.queue_task(Box::new(move || {
let mut transaction = state.new_committable_transaction(Require::indexing(), None);
transaction.as_mut().invalidate_config();

Self::validate_in_memory_for_transaction(&state, &open_files, transaction.as_mut());
Self::validate_in_memory_for_transaction_with_workspaces(Some(&workspaces), &state, &open_files, transaction.as_mut());

// Commit will be blocked until there are no ongoing reads.
// If we have some long running read jobs that can be cancelled, we should cancel them
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 @@ -233,7 +233,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 @@ -468,6 +467,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 @@ -32,4 +32,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"]
Loading