From c7e6cc266eca915330d63b91544251269f041aec Mon Sep 17 00:00:00 2001 From: Arths17 Date: Tue, 7 Apr 2026 01:04:34 -0500 Subject: [PATCH 1/6] lsp: scope fix-all actions to pyrefly kind Code action save hooks currently require enabling generic source.fixAll, which can trigger unrelated servers. This change advertises and emits source.fixAll.pyrefly so editors can target pyrefly-only fix-all behavior.\n\nThe code action filter now accepts both source.fixAll and source.fixAll.* requests so parent-kind clients remain compatible while pyrefly-specific requests are supported. --- pyrefly/lib/lsp/non_wasm/server.rs | 14 +++++++++----- pyrefly/lib/test/lsp/lsp_interaction/basic.rs | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pyrefly/lib/lsp/non_wasm/server.rs b/pyrefly/lib/lsp/non_wasm/server.rs index b2cae30a3f..e332d2efc8 100644 --- a/pyrefly/lib/lsp/non_wasm/server.rs +++ b/pyrefly/lib/lsp/non_wasm/server.rs @@ -1142,7 +1142,7 @@ pub fn capabilities( CodeActionKind::new("refactor.delete"), CodeActionKind::new("refactor.move"), CodeActionKind::REFACTOR_INLINE, - CodeActionKind::SOURCE_FIX_ALL, + CodeActionKind::new(SOURCE_FIX_ALL_PYREFLY), ]), ..Default::default() })), @@ -1252,6 +1252,7 @@ pub enum ProcessEvent { } const PYTHON_SECTION: &str = "python"; +const SOURCE_FIX_ALL_PYREFLY: &str = "source.fixAll.pyrefly"; struct TypeHierarchyTarget { def_index: ClassDefIndex, @@ -4171,9 +4172,12 @@ impl Server { let allow_quickfix = only_kinds .is_none_or(|kinds| kinds.iter().any(|kind| kind == &CodeActionKind::QUICKFIX)); let allow_fix_all = only_kinds.is_none_or(|kinds| { - kinds - .iter() - .any(|kind| kind == &CodeActionKind::SOURCE_FIX_ALL) + kinds.iter().any(|kind| { + kind == &CodeActionKind::SOURCE_FIX_ALL + || kind + .as_str() + .starts_with(CodeActionKind::SOURCE_FIX_ALL.as_str()) + }) }); let allow_refactor = only_kinds.is_none_or(|kinds| { kinds @@ -4276,7 +4280,7 @@ impl Server { if !changes.is_empty() { actions.push(CodeActionOrCommand::CodeAction(CodeAction { title: "Remove all redundant casts".to_owned(), - kind: Some(CodeActionKind::SOURCE_FIX_ALL), + kind: Some(CodeActionKind::new(SOURCE_FIX_ALL_PYREFLY)), edit: Some(WorkspaceEdit { changes: Some(changes), ..Default::default() diff --git a/pyrefly/lib/test/lsp/lsp_interaction/basic.rs b/pyrefly/lib/test/lsp/lsp_interaction/basic.rs index f9d4363b36..779f2fd4cf 100644 --- a/pyrefly/lib/test/lsp/lsp_interaction/basic.rs +++ b/pyrefly/lib/test/lsp/lsp_interaction/basic.rs @@ -36,7 +36,7 @@ fn test_initialize_basic() { "definitionProvider": true, "typeDefinitionProvider": true, "codeActionProvider": { - "codeActionKinds": ["quickfix", "refactor.extract", "refactor.rewrite", "refactor.delete", "refactor.move", "refactor.inline", "source.fixAll"] + "codeActionKinds": ["quickfix", "refactor.extract", "refactor.rewrite", "refactor.delete", "refactor.move", "refactor.inline", "source.fixAll.pyrefly"] }, "codeLensProvider": { "resolveProvider": false, From 4948b1e5634e0dbec2e4166a2b1dbdfa33842317 Mon Sep 17 00:00:00 2001 From: Arths17 Date: Tue, 7 Apr 2026 01:15:35 -0500 Subject: [PATCH 2/6] lsp: limit source.fixAll matching to pyrefly subtree When a client requests source.fixAll.*, we should not run pyrefly fix-all for sibling providers. Restrict matching to either the generic source.fixAll ancestor or source.fixAll.pyrefly descendants. --- pyrefly/lib/lsp/non_wasm/server.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyrefly/lib/lsp/non_wasm/server.rs b/pyrefly/lib/lsp/non_wasm/server.rs index e332d2efc8..ca0754b0fa 100644 --- a/pyrefly/lib/lsp/non_wasm/server.rs +++ b/pyrefly/lib/lsp/non_wasm/server.rs @@ -4174,9 +4174,7 @@ impl Server { let allow_fix_all = only_kinds.is_none_or(|kinds| { kinds.iter().any(|kind| { kind == &CodeActionKind::SOURCE_FIX_ALL - || kind - .as_str() - .starts_with(CodeActionKind::SOURCE_FIX_ALL.as_str()) + || kind.as_str().starts_with(SOURCE_FIX_ALL_PYREFLY) }) }); let allow_refactor = only_kinds.is_none_or(|kinds| { From bd89c04cdbf3b52b83af696203c94ec790c7bca1 Mon Sep 17 00:00:00 2001 From: Arths17 Date: Fri, 10 Apr 2026 22:45:45 -0500 Subject: [PATCH 3/6] lsp: require exact pyrefly fix-all kind match Reviewer feedback pointed out that prefix matching for source.fixAll.pyrefly would accept unrelated kinds such as source.fixAll.pyrefly.foo. This makes fix-all behavior broader than intended. Use exact matching for source.fixAll.pyrefly (while still accepting source.fixAll), and add regression tests that prove accepted and rejected kind values. This keeps code-action filtering aligned with LSP expectations and prevents false-positive fix-all triggers. --- pyrefly/lib/lsp/non_wasm/server.rs | 36 +++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/pyrefly/lib/lsp/non_wasm/server.rs b/pyrefly/lib/lsp/non_wasm/server.rs index ca0754b0fa..3c7c285803 100644 --- a/pyrefly/lib/lsp/non_wasm/server.rs +++ b/pyrefly/lib/lsp/non_wasm/server.rs @@ -681,7 +681,11 @@ fn format_diagnostic_message_for_markdown(message: &str) -> String { #[cfg(test)] mod tests { + use lsp_types::CodeActionKind; + + use super::SOURCE_FIX_ALL_PYREFLY; use super::format_diagnostic_message_for_markdown; + use super::is_fix_all_code_action_kind_requested; #[test] fn test_format_diagnostic_message_for_markdown() { @@ -722,6 +726,26 @@ mod tests { fn test_format_only_special_characters() { assert_eq!(format_diagnostic_message_for_markdown("***"), "\\*\\*\\*"); } + + #[test] + fn test_fix_all_kind_filter_matches_supported_kinds() { + assert!(is_fix_all_code_action_kind_requested( + &CodeActionKind::SOURCE_FIX_ALL + )); + assert!(is_fix_all_code_action_kind_requested(&CodeActionKind::new( + SOURCE_FIX_ALL_PYREFLY, + ))); + } + + #[test] + fn test_fix_all_kind_filter_rejects_pyrefly_suffix_kinds() { + assert!(!is_fix_all_code_action_kind_requested( + &CodeActionKind::new("source.fixAll.pyrefly.foo",) + )); + assert!(!is_fix_all_code_action_kind_requested( + &CodeActionKind::new("source.fixAll.pyreflyyyyyy",) + )); + } } pub struct Server { @@ -1254,6 +1278,10 @@ pub enum ProcessEvent { const PYTHON_SECTION: &str = "python"; const SOURCE_FIX_ALL_PYREFLY: &str = "source.fixAll.pyrefly"; +fn is_fix_all_code_action_kind_requested(kind: &CodeActionKind) -> bool { + kind == &CodeActionKind::SOURCE_FIX_ALL || kind.as_str() == SOURCE_FIX_ALL_PYREFLY +} + struct TypeHierarchyTarget { def_index: ClassDefIndex, module_path: ModulePath, @@ -4171,12 +4199,8 @@ impl Server { let only_kinds = params.context.only.as_ref(); let allow_quickfix = only_kinds .is_none_or(|kinds| kinds.iter().any(|kind| kind == &CodeActionKind::QUICKFIX)); - let allow_fix_all = only_kinds.is_none_or(|kinds| { - kinds.iter().any(|kind| { - kind == &CodeActionKind::SOURCE_FIX_ALL - || kind.as_str().starts_with(SOURCE_FIX_ALL_PYREFLY) - }) - }); + let allow_fix_all = + only_kinds.is_none_or(|kinds| kinds.iter().any(is_fix_all_code_action_kind_requested)); let allow_refactor = only_kinds.is_none_or(|kinds| { kinds .iter() From 594c9cc40dc251c4f1174479441000ca56703b12 Mon Sep 17 00:00:00 2001 From: Arths17 Date: Thu, 23 Apr 2026 23:37:20 -0500 Subject: [PATCH 4/6] Preserve tuple match narrows across cases Keep tuple-element narrows alive when a match case falls through to later cases, so `None` cases on multi-subject matches do not lose the surviving element types. --- pyrefly/lib/binding/pattern.rs | 40 +++++++++++++++++++------------ pyrefly/lib/test/pattern_match.rs | 19 +++++++++++++++ 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/pyrefly/lib/binding/pattern.rs b/pyrefly/lib/binding/pattern.rs index eb569835f9..c529729bc8 100644 --- a/pyrefly/lib/binding/pattern.rs +++ b/pyrefly/lib/binding/pattern.rs @@ -213,11 +213,17 @@ impl<'a> BindingsBuilder<'a> { Some(idx) => idx, Option::None => { // More patterns than tuple elements, skip narrowing - narrow_ops.and_all(self.bind_pattern( - MatchSubject::None, - x, - key_for_subpattern, - )); + for (name, (op, range)) in self + .bind_pattern( + MatchSubject::None, + x, + key_for_subpattern, + ) + .0 + { + let subject = NarrowingSubject::Name(name); + narrow_ops.and_for_subject(&subject, op, range); + } continue; } } @@ -238,11 +244,13 @@ impl<'a> BindingsBuilder<'a> { } _ => MatchSubject::None, }; - narrow_ops.and_all(self.bind_pattern( - subject_for_subpattern, - x, - key_for_subpattern, - )); + for (name, (op, range)) in self + .bind_pattern(subject_for_subpattern, x, key_for_subpattern) + .0 + { + let subject = NarrowingSubject::Name(name); + narrow_ops.and_for_subject(&subject, op, range); + } } } } @@ -599,11 +607,13 @@ impl<'a> BindingsBuilder<'a> { // shadow outer variables. When there is no narrowing subject // (e.g. `match make_color():`), drop all narrows so that alias // names don't resolve against unrelated outer variables. - new_narrow_ops.0.retain(|name, _| { - match_subject - .as_single() - .as_ref() - .is_some_and(|s| name == s.name()) + new_narrow_ops.0.retain(|name, _| match &match_subject { + MatchSubject::Single(subject) => name == subject.name(), + MatchSubject::Tuple(subjects) => subjects + .iter() + .flatten() + .any(|subject| name == subject.name()), + MatchSubject::None => false, }); negated_prev_ops.and_all(new_narrow_ops.negate()); self.stmts(body, parent); diff --git a/pyrefly/lib/test/pattern_match.rs b/pyrefly/lib/test/pattern_match.rs index ab914733d3..93e32b52d7 100644 --- a/pyrefly/lib/test/pattern_match.rs +++ b/pyrefly/lib/test/pattern_match.rs @@ -726,6 +726,25 @@ def test_sequence_after_none(seq_or_none: list[int] | None): "#, ); +testcase!( + test_match_tuple_subject_narrowing_after_none, + r#" +from typing import assert_type + +def test(a: list[int] | None, b: list[int] | None) -> None: + match a, b: + case None, None: + pass + case _, None: + assert_type(a, list[int]) + case None, _: + assert_type(b, list[int]) + case _: + assert_type(a, list[int]) + assert_type(b, list[int]) +"#, +); + testcase!( test_match_mapping_before_none, r#" From a9e47f6804aa0621e2bc82c7f01f5a39d6b45d08 Mon Sep 17 00:00:00 2001 From: Atharv Ranjan Date: Thu, 23 Apr 2026 23:44:38 -0500 Subject: [PATCH 5/6] Update pyrefly/lib/test/pattern_match.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pyrefly/lib/test/pattern_match.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyrefly/lib/test/pattern_match.rs b/pyrefly/lib/test/pattern_match.rs index 93e32b52d7..2648e926ce 100644 --- a/pyrefly/lib/test/pattern_match.rs +++ b/pyrefly/lib/test/pattern_match.rs @@ -737,7 +737,9 @@ def test(a: list[int] | None, b: list[int] | None) -> None: pass case _, None: assert_type(a, list[int]) + assert_type(b, None) case None, _: + assert_type(a, None) assert_type(b, list[int]) case _: assert_type(a, list[int]) From 44d671a67f370a43f4f045d96c695877becf8235 Mon Sep 17 00:00:00 2001 From: Atharv Ranjan Date: Thu, 23 Apr 2026 23:44:50 -0500 Subject: [PATCH 6/6] Update pyrefly/lib/lsp/non_wasm/server.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pyrefly/lib/lsp/non_wasm/server.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyrefly/lib/lsp/non_wasm/server.rs b/pyrefly/lib/lsp/non_wasm/server.rs index 3c7c285803..976c2c77b5 100644 --- a/pyrefly/lib/lsp/non_wasm/server.rs +++ b/pyrefly/lib/lsp/non_wasm/server.rs @@ -1279,7 +1279,11 @@ const PYTHON_SECTION: &str = "python"; const SOURCE_FIX_ALL_PYREFLY: &str = "source.fixAll.pyrefly"; fn is_fix_all_code_action_kind_requested(kind: &CodeActionKind) -> bool { - kind == &CodeActionKind::SOURCE_FIX_ALL || kind.as_str() == SOURCE_FIX_ALL_PYREFLY + let requested = kind.as_str(); + requested == SOURCE_FIX_ALL_PYREFLY + || SOURCE_FIX_ALL_PYREFLY + .strip_prefix(requested) + .is_some_and(|suffix| suffix.starts_with('.')) } struct TypeHierarchyTarget {