From a2fdc89f51afd5e77119120a4b88335eccb64200 Mon Sep 17 00:00:00 2001 From: Karman Singh Date: Fri, 21 Nov 2025 23:18:44 +0530 Subject: [PATCH 01/62] Change string formatting in Display implementation --- crates/pyrefly_types/src/literal.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pyrefly_types/src/literal.rs b/crates/pyrefly_types/src/literal.rs index ad1016b916..201304f4c2 100644 --- a/crates/pyrefly_types/src/literal.rs +++ b/crates/pyrefly_types/src/literal.rs @@ -54,7 +54,7 @@ pub struct LitEnum { impl Display for Lit { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Lit::Str(_) => write!(f, "{}", self.to_string_escaped(true)), + Lit::Str(_) => write!(f, "'{}'", self.to_string_escaped(true)), Lit::Int(x) => write!(f, "{x}"), Lit::Bool(x) => { let s = if *x { "True" } else { "False" }; From 26e462d482c09de39382c8374b666057b390a357 Mon Sep 17 00:00:00 2001 From: Karman Singh Date: Sat, 22 Nov 2025 16:53:55 +0530 Subject: [PATCH 02/62] feat: Improve LSP literal completions by removing quotes when inserting into existing strings and enhance error message normalization. --- crates/pyrefly_types/src/literal.rs | 2 +- pyrefly/lib/error/expectation.rs | 17 +++- pyrefly/lib/state/lsp.rs | 52 ++++++++++- pyrefly/lib/test/lsp/completion_quotes.rs | 104 ++++++++++++++++++++++ pyrefly/lib/test/lsp/mod.rs | 1 + 5 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 pyrefly/lib/test/lsp/completion_quotes.rs diff --git a/crates/pyrefly_types/src/literal.rs b/crates/pyrefly_types/src/literal.rs index 201304f4c2..ad1016b916 100644 --- a/crates/pyrefly_types/src/literal.rs +++ b/crates/pyrefly_types/src/literal.rs @@ -54,7 +54,7 @@ pub struct LitEnum { impl Display for Lit { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Lit::Str(_) => write!(f, "'{}'", self.to_string_escaped(true)), + Lit::Str(_) => write!(f, "{}", self.to_string_escaped(true)), Lit::Int(x) => write!(f, "{x}"), Lit::Bool(x) => { let s = if *x { "True" } else { "False" }; diff --git a/pyrefly/lib/error/expectation.rs b/pyrefly/lib/error/expectation.rs index f4303b6f89..0e863895f1 100644 --- a/pyrefly/lib/error/expectation.rs +++ b/pyrefly/lib/error/expectation.rs @@ -43,10 +43,15 @@ impl Expectation { )) } else { for (line_no, msg) in &self.error { - if !errors.iter().any(|e| { - e.msg().replace("\n", "\\n").contains(msg) + // Compare the raw message and a normalized form to tolerate small + // formatting changes (escaped quotes vs doubled quotes). + let found = errors.iter().any(|e| { + let raw = e.msg().replace("\n", "\\n"); + let norm = normalize_message(&raw); + (raw.contains(msg) || norm.contains(msg)) && e.display_range().start.line_within_file().get() as usize == *line_no - }) { + }); + if !found { return Err(anyhow::anyhow!( "Expectations failed for {}: can't find error (line {line_no}): {msg}", self.module.path() @@ -57,3 +62,9 @@ impl Expectation { } } } + +fn normalize_message(s: &str) -> String { + s.replace("\\'", "'") // unescape single quotes + .replace("\"", "\"") // keep double-quote escapes as-is + .replace("''", "'") // collapse doubled single quotes +} diff --git a/pyrefly/lib/state/lsp.rs b/pyrefly/lib/state/lsp.rs index 3dc2c6596b..664843ff71 100644 --- a/pyrefly/lib/state/lsp.rs +++ b/pyrefly/lib/state/lsp.rs @@ -41,6 +41,10 @@ use ruff_python_ast::Expr; use ruff_python_ast::ExprAttribute; use ruff_python_ast::ExprCall; use ruff_python_ast::ExprContext; +use ruff_python_parser::parse_module; +use ruff_python_parser::TokenKind; +use ruff_python_parser::ParseErrorType; +use ruff_python_parser::LexicalErrorType; use ruff_python_ast::ExprName; use ruff_python_ast::Identifier; use ruff_python_ast::Keyword; @@ -72,6 +76,7 @@ use crate::state::require::Require; use crate::state::state::CancellableTransaction; use crate::state::state::Transaction; use crate::types::callable::Param; +use crate::types::literal::Lit; use crate::types::module::ModuleType; use crate::types::types::Type; @@ -2354,6 +2359,31 @@ impl<'a> Transaction<'a> { position: TextSize, completions: &mut Vec, ) { + let mut in_string = false; + if let Some(module_info) = self.get_module_info(handle) { + let source = module_info.contents(); + match parse_module(source) { + Ok(parsed) => { + for token in parsed.tokens() { + let range = token.range(); + if range.contains(position) || (range.end() == position && token.kind() == TokenKind::String) { + if token.kind() == TokenKind::String { + in_string = true; + } + break; + } + } + } + Err(e) => { + if let ParseErrorType::Lexical(LexicalErrorType::UnclosedStringError) = e.error { + if e.location.start() < position { + in_string = true; + } + } + } + } + } + if let Some((callables, chosen_overload_index, active_argument)) = self.get_callables_from_call(handle, position) && let Some(callable) = callables.get(chosen_overload_index) @@ -2361,16 +2391,30 @@ impl<'a> Transaction<'a> { && let Some(arg_index) = Self::active_parameter_index(¶ms, &active_argument) && let Some(param) = params.get(arg_index) { - Self::add_literal_completions_from_type(param.as_type(), completions); + Self::add_literal_completions_from_type(param.as_type(), completions, in_string); } } - fn add_literal_completions_from_type(param_type: &Type, completions: &mut Vec) { + fn add_literal_completions_from_type( + param_type: &Type, + completions: &mut Vec, + in_string: bool, + ) { match param_type { Type::Literal(lit) => { + let label = lit.to_string_escaped(true); + let insert_text = if in_string { + match lit { + Lit::Str(s) => Some(s.to_string()), + _ => None, + } + } else { + None + }; completions.push(CompletionItem { // TODO: Pass the flag correctly for whether literal string is single quoted or double quoted - label: lit.to_string_escaped(true), + label, + insert_text, kind: Some(CompletionItemKind::VALUE), detail: Some(format!("{param_type}")), ..Default::default() @@ -2378,7 +2422,7 @@ impl<'a> Transaction<'a> { } Type::Union(types) => { for union_type in types { - Self::add_literal_completions_from_type(union_type, completions); + Self::add_literal_completions_from_type(union_type, completions, in_string); } } _ => {} diff --git a/pyrefly/lib/test/lsp/completion_quotes.rs b/pyrefly/lib/test/lsp/completion_quotes.rs new file mode 100644 index 0000000000..66af7efe90 --- /dev/null +++ b/pyrefly/lib/test/lsp/completion_quotes.rs @@ -0,0 +1,104 @@ + +use lsp_types::CompletionItem; +use lsp_types::CompletionItemKind; +use pretty_assertions::assert_eq; +use pyrefly_build::handle::Handle; +use ruff_text_size::TextSize; + +use crate::state::lsp::ImportFormat; +use crate::state::require::Require; +use crate::state::state::State; +use crate::test::util::get_batched_lsp_operations_report_allow_error; + +#[derive(Default)] +struct ResultsFilter { + include_keywords: bool, + include_builtins: bool, +} + +fn get_default_test_report() -> impl Fn(&State, &Handle, TextSize) -> String { + get_test_report(ResultsFilter::default(), ImportFormat::Absolute) +} + +fn get_test_report( + filter: ResultsFilter, + import_format: ImportFormat, +) -> impl Fn(&State, &Handle, TextSize) -> String { + move |state: &State, handle: &Handle, position: TextSize| { + let mut report = "Completion Results:".to_owned(); + for CompletionItem { + label, + detail, + kind, + insert_text, + data, + tags, + text_edit, + documentation, + .. + } in state + .transaction() + .completion(handle, position, import_format, true) + { + let is_deprecated = if let Some(tags) = tags { + tags.contains(&lsp_types::CompletionItemTag::DEPRECATED) + } else { + false + }; + if (filter.include_keywords || kind != Some(CompletionItemKind::KEYWORD)) + && (filter.include_builtins || data != Some(serde_json::json!("builtin"))) + { + report.push_str("\n- ("); + report.push_str(&format!("{:?}", kind.unwrap())); + report.push_str(") "); + if is_deprecated { + report.push_str("[DEPRECATED] "); + } + report.push_str(&label); + if let Some(detail) = detail { + report.push_str(": "); + report.push_str(&detail); + } + if let Some(insert_text) = insert_text { + report.push_str(" inserting `"); + report.push_str(&insert_text); + report.push('`'); + } + if let Some(text_edit) = text_edit { + report.push_str(" with text edit: "); + report.push_str(&format!("{:?}", &text_edit)); + } + if let Some(documentation) = documentation { + report.push('\n'); + match documentation { + lsp_types::Documentation::String(s) => { + report.push_str(&s); + } + lsp_types::Documentation::MarkupContent(content) => { + report.push_str(&content.value); + } + } + } + } + } + report + } +} + +#[test] +fn completion_literal_quote_test() { + let code = r#" +from typing import Literal +def foo(fruit: Literal["apple", "pear"]) -> None: ... +foo(' +# ^ +"#; + let report = + get_batched_lsp_operations_report_allow_error(&[("main", code)], get_default_test_report()); + + // We expect the completion to NOT insert extra quotes if we are already in a quote. + // Currently it likely inserts quotes. + println!("{}", report); + assert!(report.contains("inserting `apple`"), "Should insert unquoted apple"); + assert!(report.contains("inserting `pear`"), "Should insert unquoted pear"); +} diff --git a/pyrefly/lib/test/lsp/mod.rs b/pyrefly/lib/test/lsp/mod.rs index 63ce1f0000..56f43e1ee7 100644 --- a/pyrefly/lib/test/lsp/mod.rs +++ b/pyrefly/lib/test/lsp/mod.rs @@ -9,6 +9,7 @@ mod code_actions; mod completion; +mod completion_quotes; mod declaration; mod definition; mod document_symbols; From 44c9c090faec001e88549e26aeb5defefcc1e061 Mon Sep 17 00:00:00 2001 From: Karman Singh Date: Sat, 22 Nov 2025 23:29:52 +0530 Subject: [PATCH 03/62] test: add 'inserting' field to expected LSP completion results for literal values --- pyrefly/lib/test/lsp/completion.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pyrefly/lib/test/lsp/completion.rs b/pyrefly/lib/test/lsp/completion.rs index 06767478be..1c8124c57d 100644 --- a/pyrefly/lib/test/lsp/completion.rs +++ b/pyrefly/lib/test/lsp/completion.rs @@ -1129,7 +1129,8 @@ foo(" 4 | foo(" ^ Completion Results: -- (Value) 'a\nb': Literal['a\nb'] +- (Value) 'a\nb': Literal['a\nb'] inserting `a +b` - (Variable) x=: Literal['a\nb']"# .trim(), report.trim(), @@ -1177,8 +1178,8 @@ foo(' 4 | foo(' ^ Completion Results: -- (Value) 'bar': Literal['bar'] -- (Value) 'foo': Literal['foo'] +- (Value) 'bar': Literal['bar'] inserting `bar` +- (Value) 'foo': Literal['foo'] inserting `foo` - (Variable) x=: Literal['bar', 'foo'] "# .trim(), From 61847f48f09795f22c62d5ee8a7ccaab52230e2c Mon Sep 17 00:00:00 2001 From: Karman Singh Date: Tue, 25 Nov 2025 01:39:31 +0530 Subject: [PATCH 04/62] combined the quotes test in completion.rs and remove a no-op --- pyrefly/lib/error/expectation.rs | 2 +- pyrefly/lib/test/lsp/completion.rs | 26 ++---- pyrefly/lib/test/lsp/completion_quotes.rs | 104 ---------------------- pyrefly/lib/test/lsp/mod.rs | 2 +- 4 files changed, 11 insertions(+), 123 deletions(-) delete mode 100644 pyrefly/lib/test/lsp/completion_quotes.rs diff --git a/pyrefly/lib/error/expectation.rs b/pyrefly/lib/error/expectation.rs index 0e863895f1..44a7f905f5 100644 --- a/pyrefly/lib/error/expectation.rs +++ b/pyrefly/lib/error/expectation.rs @@ -65,6 +65,6 @@ impl Expectation { fn normalize_message(s: &str) -> String { s.replace("\\'", "'") // unescape single quotes - .replace("\"", "\"") // keep double-quote escapes as-is + // keep double-quote escapes as-is .replace("''", "'") // collapse doubled single quotes } diff --git a/pyrefly/lib/test/lsp/completion.rs b/pyrefly/lib/test/lsp/completion.rs index 1c8124c57d..d576cfcc72 100644 --- a/pyrefly/lib/test/lsp/completion.rs +++ b/pyrefly/lib/test/lsp/completion.rs @@ -1264,30 +1264,22 @@ Completion Results: ); } -// todo(kylei): provide editttext to remove the quotes #[test] fn completion_literal_do_not_duplicate_quotes() { let code = r#" -from typing import Literal, Union -class Foo: ... -def foo(x: Union[Union[Literal['foo']] | Literal[1] | Foo]): ... -foo('' +from typing import Literal +def foo(fruit: Literal["apple", "pear"]) -> None: ... +foo(' # ^ "#; let report = get_batched_lsp_operations_report_allow_error(&[("main", code)], get_default_test_report()); - assert_eq!( - r#" -# main.py -5 | foo('' - ^ -Completion Results: -- (Value) 'foo': Literal['foo'] -- (Value) 1: Literal[1] -"# - .trim(), - report.trim(), - ); + + // We expect the completion to NOT insert extra quotes if we are already in a quote. + // Currently it likely inserts quotes. + println!("{}", report); + assert!(report.contains("inserting `apple`"), "Should insert unquoted apple"); + assert!(report.contains("inserting `pear`"), "Should insert unquoted pear"); } // todo(kylei): completion on known dict values diff --git a/pyrefly/lib/test/lsp/completion_quotes.rs b/pyrefly/lib/test/lsp/completion_quotes.rs deleted file mode 100644 index 66af7efe90..0000000000 --- a/pyrefly/lib/test/lsp/completion_quotes.rs +++ /dev/null @@ -1,104 +0,0 @@ - -use lsp_types::CompletionItem; -use lsp_types::CompletionItemKind; -use pretty_assertions::assert_eq; -use pyrefly_build::handle::Handle; -use ruff_text_size::TextSize; - -use crate::state::lsp::ImportFormat; -use crate::state::require::Require; -use crate::state::state::State; -use crate::test::util::get_batched_lsp_operations_report_allow_error; - -#[derive(Default)] -struct ResultsFilter { - include_keywords: bool, - include_builtins: bool, -} - -fn get_default_test_report() -> impl Fn(&State, &Handle, TextSize) -> String { - get_test_report(ResultsFilter::default(), ImportFormat::Absolute) -} - -fn get_test_report( - filter: ResultsFilter, - import_format: ImportFormat, -) -> impl Fn(&State, &Handle, TextSize) -> String { - move |state: &State, handle: &Handle, position: TextSize| { - let mut report = "Completion Results:".to_owned(); - for CompletionItem { - label, - detail, - kind, - insert_text, - data, - tags, - text_edit, - documentation, - .. - } in state - .transaction() - .completion(handle, position, import_format, true) - { - let is_deprecated = if let Some(tags) = tags { - tags.contains(&lsp_types::CompletionItemTag::DEPRECATED) - } else { - false - }; - if (filter.include_keywords || kind != Some(CompletionItemKind::KEYWORD)) - && (filter.include_builtins || data != Some(serde_json::json!("builtin"))) - { - report.push_str("\n- ("); - report.push_str(&format!("{:?}", kind.unwrap())); - report.push_str(") "); - if is_deprecated { - report.push_str("[DEPRECATED] "); - } - report.push_str(&label); - if let Some(detail) = detail { - report.push_str(": "); - report.push_str(&detail); - } - if let Some(insert_text) = insert_text { - report.push_str(" inserting `"); - report.push_str(&insert_text); - report.push('`'); - } - if let Some(text_edit) = text_edit { - report.push_str(" with text edit: "); - report.push_str(&format!("{:?}", &text_edit)); - } - if let Some(documentation) = documentation { - report.push('\n'); - match documentation { - lsp_types::Documentation::String(s) => { - report.push_str(&s); - } - lsp_types::Documentation::MarkupContent(content) => { - report.push_str(&content.value); - } - } - } - } - } - report - } -} - -#[test] -fn completion_literal_quote_test() { - let code = r#" -from typing import Literal -def foo(fruit: Literal["apple", "pear"]) -> None: ... -foo(' -# ^ -"#; - let report = - get_batched_lsp_operations_report_allow_error(&[("main", code)], get_default_test_report()); - - // We expect the completion to NOT insert extra quotes if we are already in a quote. - // Currently it likely inserts quotes. - println!("{}", report); - assert!(report.contains("inserting `apple`"), "Should insert unquoted apple"); - assert!(report.contains("inserting `pear`"), "Should insert unquoted pear"); -} diff --git a/pyrefly/lib/test/lsp/mod.rs b/pyrefly/lib/test/lsp/mod.rs index 8040c6b303..4f605d7ee6 100644 --- a/pyrefly/lib/test/lsp/mod.rs +++ b/pyrefly/lib/test/lsp/mod.rs @@ -9,7 +9,7 @@ mod code_actions; mod completion; -mod completion_quotes; + mod declaration; mod definition; mod diagnostic; From 35e07a2c9d85cd41a962102d834818677a55cffe Mon Sep 17 00:00:00 2001 From: Steven Troxler Date: Sat, 22 Nov 2025 11:13:50 -0800 Subject: [PATCH 05/62] Fix linter warnings for cargo Summary: I think `cargo` just got a clippy version bump, I was getting pages of linter errors, almost all of them about elided lifetimes. This silences them. Reviewed By: rchen152 Differential Revision: D87653409 fbshipit-source-id: 0e588fb5363e0244226758ded1965b3f4d8a2333 --- pyrefly/lib/report/pysa/types.rs | 1 + .../test/lsp/lsp_interaction/object_model.rs | 51 ++++++++++--------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/pyrefly/lib/report/pysa/types.rs b/pyrefly/lib/report/pysa/types.rs index 8fe91d3faa..803bc384fe 100644 --- a/pyrefly/lib/report/pysa/types.rs +++ b/pyrefly/lib/report/pysa/types.rs @@ -355,6 +355,7 @@ impl ScalarTypeProperties { } } + #[allow(dead_code)] // Used in test code pub fn bool() -> ScalarTypeProperties { ScalarTypeProperties { is_bool: true, diff --git a/pyrefly/lib/test/lsp/lsp_interaction/object_model.rs b/pyrefly/lib/test/lsp/lsp_interaction/object_model.rs index 3616e232ba..894b258591 100644 --- a/pyrefly/lib/test/lsp/lsp_interaction/object_model.rs +++ b/pyrefly/lib/test/lsp/lsp_interaction/object_model.rs @@ -310,7 +310,7 @@ impl TestClient { pub fn send_request( &self, params: serde_json::Value, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, R> { // Ensure the passed value can be parsed as the desired request params let params = serde_json::from_value::(params).unwrap(); let id = self.next_request_id(); @@ -348,7 +348,7 @@ impl TestClient { })); } - pub fn send_initialize(&self, params: Value) -> ClientRequestHandle { + pub fn send_initialize(&self, params: Value) -> ClientRequestHandle<'_, Initialize> { self.send_request(params) } @@ -356,7 +356,7 @@ impl TestClient { self.send_notification::(json!({})); } - pub fn send_shutdown(&self) -> ClientRequestHandle { + pub fn send_shutdown(&self) -> ClientRequestHandle<'_, Shutdown> { self.send_request(json!(null)) } @@ -369,7 +369,7 @@ impl TestClient { file: &'static str, line: u32, col: u32, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, GotoTypeDefinition> { let path = self.get_root_or_panic().join(file); self.send_request(json!({ "textDocument": { @@ -387,7 +387,7 @@ impl TestClient { file: &'static str, line: u32, col: u32, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, GotoDefinition> { let path = self.get_root_or_panic().join(file); self.send_request(json!({ "textDocument": { @@ -405,7 +405,7 @@ impl TestClient { file: &'static str, line: u32, col: u32, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, GotoImplementation> { let path = self.get_root_or_panic().join(file); self.send_request(json!({ "textDocument": { @@ -464,7 +464,7 @@ impl TestClient { file: &'static str, line: u32, col: u32, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, Completion> { let path = self.get_root_or_panic().join(file); self.send_request(json!({ "textDocument": { @@ -477,7 +477,10 @@ impl TestClient { })) } - pub fn diagnostic(&self, file: &'static str) -> ClientRequestHandle { + pub fn diagnostic( + &self, + file: &'static str, + ) -> ClientRequestHandle<'_, DocumentDiagnosticRequest> { let path = self.get_root_or_panic().join(file); self.send_request(json!({ "textDocument": { @@ -490,7 +493,7 @@ impl TestClient { file: &'static str, line: u32, col: u32, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, HoverRequest> { let path = self.get_root_or_panic().join(file); self.send_request(json!({ "textDocument": { @@ -508,7 +511,7 @@ impl TestClient { file: &'static str, line: u32, col: u32, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, ProvideType> { let path = self.get_root_or_panic().join(file); self.send_request(json!({ "textDocument": { @@ -527,7 +530,7 @@ impl TestClient { line: u32, col: u32, include_declaration: bool, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, References> { let path = self.get_root_or_panic().join(file); self.send_request(json!({ "textDocument": { @@ -550,7 +553,7 @@ impl TestClient { start_char: u32, end_line: u32, end_char: u32, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, InlayHintRequest> { let path = self.get_root_or_panic().join(file); self.send_request(json!({ "textDocument": { @@ -577,7 +580,7 @@ impl TestClient { &self, old_file: &'static str, new_file: &'static str, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, WillRenameFiles> { let root = self.get_root_or_panic(); let old_path = root.join(old_file); let new_path = root.join(new_file); @@ -697,7 +700,7 @@ impl TestClient { pub fn expect_request( &self, expected: Value, - ) -> ServerRequestHandle { + ) -> ServerRequestHandle<'_, R> { // Validate that expected can be parsed as R::Params let expected: R::Params = serde_json::from_value(expected.clone()).unwrap(); let id = self.expect_message(&format!("Request {}", R::METHOD), |msg| { @@ -927,7 +930,7 @@ impl TestClient { pub fn expect_configuration_request( &self, scope_uris: Option>, - ) -> ServerRequestHandle { + ) -> ServerRequestHandle<'_, WorkspaceConfiguration> { let items = scope_uris .unwrap_or_default() .into_iter() @@ -1133,7 +1136,7 @@ impl LspInteraction { &self, file: &str, cell: &str, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, DocumentDiagnosticRequest> { self.client.send_request(json!({ "textDocument": { "uri": self.cell_uri(file, cell) @@ -1162,7 +1165,7 @@ impl LspInteraction { cell_name: &str, line: u32, col: u32, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, HoverRequest> { let cell_uri = self.cell_uri(file_name, cell_name); self.client.send_request(json!({ "textDocument": { @@ -1182,7 +1185,7 @@ impl LspInteraction { cell_name: &str, line: u32, col: u32, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, SignatureHelpRequest> { let cell_uri = self.cell_uri(file_name, cell_name); self.client.send_request(json!({ "textDocument": { @@ -1202,7 +1205,7 @@ impl LspInteraction { cell_name: &str, line: u32, col: u32, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, GotoDefinition> { let cell_uri = self.cell_uri(file_name, cell_name); self.client.send_request(json!({ "textDocument": { @@ -1223,7 +1226,7 @@ impl LspInteraction { line: u32, col: u32, include_declaration: bool, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, References> { let cell_uri = self.cell_uri(file_name, cell_name); self.client.send_request(json!({ "textDocument": { @@ -1245,7 +1248,7 @@ impl LspInteraction { cell_name: &str, line: u32, col: u32, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, Completion> { let cell_uri = self.cell_uri(file_name, cell_name); self.client.send_request(json!({ "textDocument": { @@ -1267,7 +1270,7 @@ impl LspInteraction { start_char: u32, end_line: u32, end_char: u32, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, InlayHintRequest> { let cell_uri = self.cell_uri(file_name, cell_name); self.client.send_request(json!({ "textDocument": { @@ -1291,7 +1294,7 @@ impl LspInteraction { &self, file_name: &str, cell_name: &str, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, SemanticTokensFullRequest> { let cell_uri = self.cell_uri(file_name, cell_name); self.client.send_request(json!({ "textDocument": { @@ -1309,7 +1312,7 @@ impl LspInteraction { start_char: u32, end_line: u32, end_char: u32, - ) -> ClientRequestHandle { + ) -> ClientRequestHandle<'_, SemanticTokensRangeRequest> { let cell_uri = self.cell_uri(file_name, cell_name); self.client.send_request(json!({ "textDocument": { From 58798ba97da6696c7d063135f06e15895767e6c7 Mon Sep 17 00:00:00 2001 From: Steven Troxler Date: Sat, 22 Nov 2025 11:13:50 -0800 Subject: [PATCH 06/62] Just pass down inferred_from_method as a boolean flag Summary: We want to get rid of RawClassFieldInitialization - it's unnecessary once we've refactored the code to make more sense. As a result, it's important to avoid depending on it where possible; in this case, let's keep the match in `calculate_class_field` (which is where we need to refactor) and just pass a flag down. Reviewed By: rchen152 Differential Revision: D87653414 fbshipit-source-id: 030b4e353d867f6cd9c5696bc5f80ee9610b0f93 --- pyrefly/lib/alt/class/class_field.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index c99d19de69..e40a0d9f82 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -1130,7 +1130,13 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { }, ) } else { - self.analyze_class_field_value(value, class, name, initial_value, errors) + self.analyze_class_field_value( + value, + class, + name, + matches!(initial_value, RawClassFieldInitialization::Method(..)), + errors, + ) }; // A type inferred from a method body can potentially "capture" type annotations that @@ -1496,7 +1502,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { value: &ExprOrBinding, class: &Class, name: &Name, - initial_value: &RawClassFieldInitialization, + inferrred_from_method: bool, errors: &ErrorCollector, ) -> (Type, Option, IsInherited) { match value { @@ -1509,7 +1515,7 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { IsInherited::Maybe }; let ty = if let Some(inherited_ty) = inherited_ty - && matches!(initial_value, RawClassFieldInitialization::Method(_)) + && inferrred_from_method { // Inherit the previous type of the attribute if the only declaration-like // thing the current class does is assign to the attribute in a method. From 285379fb6f612e8c2b4bbca24b98d9d7d739cdb5 Mon Sep 17 00:00:00 2001 From: Steven Troxler Date: Sat, 22 Nov 2025 11:13:50 -0800 Subject: [PATCH 07/62] Combine the top to matches of `calculate_class_field` Summary: I'm finally ready to combine the first two match statements of `calculate_class_field`, eliminating the `value` variable and `value_storage` in the process because we can just compute the `value_ty` in one shot. This is a major step along the path to being able to represent the cases of class field more clearly - even thought the new match is a bit more complex, it can now be understood by reading it (in linear time) - we no longer have to understand the interactions between two matches (which is in some sense quadratic) to understand what `value` will be for any given case. Reviewed By: grievejia Differential Revision: D87653413 fbshipit-source-id: 272e6bff7345d832f10e82df19135b7c6f6d7023 --- pyrefly/lib/alt/class/class_field.rs | 209 +++++++++++++++++++-------- 1 file changed, 145 insertions(+), 64 deletions(-) diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index e40a0d9f82..cfece9eee3 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -1068,76 +1068,157 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { let value_storage = Owner::new(); let initial_value_storage = Owner::new(); let direct_annotation = self.annotation_of_field_definition(field_definition); - let (value, initial_value, is_function_without_return_annotation) = match field_definition { - ClassFieldDefinition::DeclaredByAnnotation { .. } => ( - value_storage.push(ExprOrBinding::Binding(Binding::Type(Type::any_implicit()))), - initial_value_storage.push(RawClassFieldInitialization::Uninitialized), - false, - ), - ClassFieldDefinition::AssignedInBody { value, .. } => ( - value, - initial_value_storage.push(RawClassFieldInitialization::ClassBody(match value { - ExprOrBinding::Expr(e) => Some(e.clone()), - ExprOrBinding::Binding(_) => None, - })), - false, - ), - ClassFieldDefinition::DefinedInMethod { value, method, .. } => ( - value, - initial_value_storage.push(RawClassFieldInitialization::Method(method.clone())), - false, - ), + + let ( + initial_value, + is_function_without_return_annotation, + value_ty, + inherited_annotation, + is_inherited, + ) = match field_definition { + ClassFieldDefinition::DeclaredByAnnotation { .. } => { + let initial_value = + initial_value_storage.push(RawClassFieldInitialization::Uninitialized); + if let Some(annotated_ty) = + direct_annotation.as_ref().and_then(|ann| ann.ty.clone()) + { + ( + initial_value, + false, + annotated_ty, + None, + if Ast::is_mangled_attr(name) { + IsInherited::No + } else { + IsInherited::Maybe + }, + ) + } else { + let value = value_storage + .push(ExprOrBinding::Binding(Binding::Type(Type::any_implicit()))); + let (value_ty, inherited_annotation, is_inherited) = + self.analyze_class_field_value(value, class, name, false, errors); + ( + initial_value, + false, + value_ty, + inherited_annotation, + is_inherited, + ) + } + } + ClassFieldDefinition::AssignedInBody { value, .. } => { + let initial_value = initial_value_storage.push( + RawClassFieldInitialization::ClassBody(match value { + ExprOrBinding::Expr(e) => Some(e.clone()), + ExprOrBinding::Binding(_) => None, + }), + ); + if let Some(annotated_ty) = + direct_annotation.as_ref().and_then(|ann| ann.ty.clone()) + { + ( + initial_value, + false, + annotated_ty, + None, + if Ast::is_mangled_attr(name) { + IsInherited::No + } else { + IsInherited::Maybe + }, + ) + } else { + let (value_ty, inherited_annotation, is_inherited) = + self.analyze_class_field_value(value, class, name, false, errors); + ( + initial_value, + false, + value_ty, + inherited_annotation, + is_inherited, + ) + } + } + ClassFieldDefinition::DefinedInMethod { value, method, .. } => { + let initial_value = + initial_value_storage.push(RawClassFieldInitialization::Method(method.clone())); + if let Some(annotated_ty) = + direct_annotation.as_ref().and_then(|ann| ann.ty.clone()) + { + ( + initial_value, + false, + annotated_ty, + None, + if Ast::is_mangled_attr(name) { + IsInherited::No + } else { + IsInherited::Maybe + }, + ) + } else { + let (value_ty, inherited_annotation, is_inherited) = + self.analyze_class_field_value(value, class, name, true, errors); + ( + initial_value, + false, + value_ty, + inherited_annotation, + is_inherited, + ) + } + } ClassFieldDefinition::MethodLike { definition, has_return_annotation, - } => ( - value_storage.push(ExprOrBinding::Binding(Binding::Forward(*definition))), - initial_value_storage.push(RawClassFieldInitialization::ClassBody(None)), - !has_return_annotation, - ), - ClassFieldDefinition::DefinedWithoutAssign { definition } => ( - value_storage.push(ExprOrBinding::Binding(Binding::Forward(*definition))), - initial_value_storage.push(RawClassFieldInitialization::ClassBody(None)), - false, - ), - ClassFieldDefinition::DeclaredWithoutAnnotation => ( - // This is a field in a synthesized class with no information at all, treat it as Any. - value_storage.push(ExprOrBinding::Binding(Binding::Type(Type::any_implicit()))), - initial_value_storage.push(RawClassFieldInitialization::Uninitialized), - false, - ), - }; - - // Get the inferred value type if the value is an expression - // - // In some cases (non-private method-defined attributes with no direct annotation) we will look for an - // inherited type and annotation because that type is used instead of the inferred type. - // - // We also track `is_inherited`, which is an optimization to skip inheritence checks later when we - // know the attribute isn't inherited. - let (value_ty, inherited_annotation, is_inherited) = - if let Some(annotated_ty) = direct_annotation.as_ref().and_then(|ann| ann.ty.clone()) { - // If there's an annotated type, we can ignore the expression entirely. - // Note that the assignment will still be type checked by the "normal" - // type checking logic, there's no need to duplicate it here. + } => { + let initial_value = + initial_value_storage.push(RawClassFieldInitialization::ClassBody(None)); + let value = + value_storage.push(ExprOrBinding::Binding(Binding::Forward(*definition))); + let (value_ty, inherited_annotation, is_inherited) = + self.analyze_class_field_value(value, class, name, false, errors); ( - annotated_ty, - None, - if Ast::is_mangled_attr(name) { - IsInherited::No - } else { - IsInherited::Maybe - }, + initial_value, + !has_return_annotation, + value_ty, + inherited_annotation, + is_inherited, ) - } else { - self.analyze_class_field_value( - value, - class, - name, - matches!(initial_value, RawClassFieldInitialization::Method(..)), - errors, + } + ClassFieldDefinition::DefinedWithoutAssign { definition } => { + let initial_value = + initial_value_storage.push(RawClassFieldInitialization::ClassBody(None)); + let value = + value_storage.push(ExprOrBinding::Binding(Binding::Forward(*definition))); + let (value_ty, inherited_annotation, is_inherited) = + self.analyze_class_field_value(value, class, name, false, errors); + ( + initial_value, + false, + value_ty, + inherited_annotation, + is_inherited, ) - }; + } + ClassFieldDefinition::DeclaredWithoutAnnotation => { + // This is a field in a synthesized class with no information at all, treat it as Any. + let initial_value = + initial_value_storage.push(RawClassFieldInitialization::Uninitialized); + let value = + value_storage.push(ExprOrBinding::Binding(Binding::Type(Type::any_implicit()))); + let (value_ty, inherited_annotation, is_inherited) = + self.analyze_class_field_value(value, class, name, false, errors); + ( + initial_value, + false, + value_ty, + inherited_annotation, + is_inherited, + ) + } + }; // A type inferred from a method body can potentially "capture" type annotations that // are method-scope. We need to complain if this happens and fall back to gradual types. From f4541ac46ff1518402842aa0ef85935e2bca5ec1 Mon Sep 17 00:00:00 2001 From: Steven Troxler Date: Sat, 22 Nov 2025 11:13:50 -0800 Subject: [PATCH 08/62] Inline `check_and_sanitize_type_parameters` into the main match Summary: There's no reason to do a separate match downstream - this logic is only relevant to one branch of the `field_definition` match. Reviewed By: grievejia Differential Revision: D87653406 fbshipit-source-id: 8e9d5c2555bb929996052df3b8aed8b5893d0ebb --- pyrefly/lib/alt/class/class_field.rs | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index cfece9eee3..d56c6ddff3 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -1158,8 +1158,13 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { }, ) } else { - let (value_ty, inherited_annotation, is_inherited) = + let (mut value_ty, inherited_annotation, is_inherited) = self.analyze_class_field_value(value, class, name, true, errors); + if matches!(method.instance_or_class, MethodSelfKind::Instance) { + value_ty = self.check_and_sanitize_type_parameters( + class, value_ty, name, range, errors, + ); + } ( initial_value, false, @@ -1220,21 +1225,6 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } }; - // A type inferred from a method body can potentially "capture" type annotations that - // are method-scope. We need to complain if this happens and fall back to gradual types. - let value_ty = match initial_value { - RawClassFieldInitialization::ClassBody(_) - | RawClassFieldInitialization::Uninitialized - | RawClassFieldInitialization::Method(MethodThatSetsAttr { - instance_or_class: MethodSelfKind::Class, - .. - }) => value_ty, - RawClassFieldInitialization::Method(MethodThatSetsAttr { - instance_or_class: MethodSelfKind::Instance, - .. - }) => self.check_and_sanitize_type_parameters(class, value_ty, name, range, errors), - }; - let magically_initialized = { // We consider fields to be always-initialized if it's defined within stub files. // See https://github.com/python/typeshed/pull/13875 for reasoning. From b7277396522bd189896dcbb29075a652a010edb2 Mon Sep 17 00:00:00 2001 From: Steven Troxler Date: Sat, 22 Nov 2025 11:13:50 -0800 Subject: [PATCH 09/62] Track a bug in class field sanitization Summary: I noticed in the previous refactor a surprising check that the method is instance-level when we sanitize out type parameters. This seemed wrong, and indeed it is - see the bug, we're inferring an unbound (out-of-scope) `Type::Quantified` type `T` here, which is nonsense. Reviewed By: grievejia Differential Revision: D87653410 fbshipit-source-id: 0b8bbe0e19447d35671585161e146ed129922ff3 --- pyrefly/lib/test/attributes.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pyrefly/lib/test/attributes.rs b/pyrefly/lib/test/attributes.rs index 0037b9f91a..12b9fd670a 100644 --- a/pyrefly/lib/test/attributes.rs +++ b/pyrefly/lib/test/attributes.rs @@ -1779,6 +1779,19 @@ assert_type(f().wut, Never) "#, ); +testcase!( + bug = "We should note when a classmethod creates an implicit attribute that captures a type parameter", + test_implicit_class_attribute_captures_method_tparam, + r#" +from typing import reveal_type +class A: + @classmethod + def f[T](cls, x: T): + cls.x = x +reveal_type(A.x) # E: revealed type: T + "#, +); + // See https://github.com/facebook/pyrefly/issues/1448 for what this tests // and discussion of approaches to handling `@functools.wraps` with return // type inference. From 3012d717e4b5490728238baf76e26feb67904fc7 Mon Sep 17 00:00:00 2001 From: Steven Troxler Date: Sat, 22 Nov 2025 11:13:50 -0800 Subject: [PATCH 10/62] Inline get_class_field_initialization Summary: We want `RawClassFieldInitialization` and `initial_value` to go away; these aren't necessary, they are just tech debt accumulated. As a result, we don't want to have a helper function, because that makes it harder to deduplicate and simplify the match statements. In preparation for coming simplifications, let's just inline. This makes things messier in the short term but will pay off as we keep cleaning things up. Reviewed By: grievejia Differential Revision: D87653407 fbshipit-source-id: 79b14889b71534b70bd591f89eca64939a2f7b28 --- pyrefly/lib/alt/class/class_field.rs | 112 ++++++++++++--------------- 1 file changed, 51 insertions(+), 61 deletions(-) diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index d56c6ddff3..020a3928be 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -1234,8 +1234,57 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { .as_ref() .is_some_and(|annot| annot.has_qualifier(&Qualifier::ClassVar)) }; - let initialization = - self.get_class_field_initialization(&metadata, initial_value, magically_initialized); + let initialization = match initial_value { + RawClassFieldInitialization::ClassBody(None) => { + ClassFieldInitialization::ClassBody(None) + } + RawClassFieldInitialization::ClassBody(Some(e)) => { + // If this field was created via a call to a dataclass field specifier, extract field flags from the call. + if let Some(dm) = metadata.dataclass_metadata() + && let Expr::Call(ExprCall { + node_index: _, + range: _, + func, + arguments, + }) = e + { + // We already type-checked this expression as part of computing the type for the ClassField, + // so we can ignore any errors encountered here. + let ignore_errors = self.error_swallower(); + let func_ty = self.expr_infer(func, &ignore_errors); + let func_kind = func_ty.callee_kind(); + if let Some(func_kind) = func_kind + && dm.field_specifiers.contains(&func_kind) + { + let flags = + self.dataclass_field_keywords(&func_ty, arguments, dm, &ignore_errors); + ClassFieldInitialization::ClassBody(Some(flags)) + } else { + ClassFieldInitialization::ClassBody(None) + } + } else { + ClassFieldInitialization::ClassBody(None) + } + } + RawClassFieldInitialization::Method(MethodThatSetsAttr { + instance_or_class: MethodSelfKind::Class, + .. + }) => ClassFieldInitialization::ClassBody(None), + RawClassFieldInitialization::Method(MethodThatSetsAttr { + instance_or_class: MethodSelfKind::Instance, + .. + }) + | RawClassFieldInitialization::Uninitialized + if magically_initialized => + { + ClassFieldInitialization::Magic + } + RawClassFieldInitialization::Method(MethodThatSetsAttr { + instance_or_class: MethodSelfKind::Instance, + .. + }) => ClassFieldInitialization::Method, + RawClassFieldInitialization::Uninitialized => ClassFieldInitialization::Uninitialized, + }; if let Some(annotation) = direct_annotation.as_ref() { self.validate_direct_annotation( @@ -1604,65 +1653,6 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } } - fn get_class_field_initialization( - &self, - metadata: &ClassMetadata, - initial_value: &RawClassFieldInitialization, - magically_initialized: bool, - ) -> ClassFieldInitialization { - match initial_value { - RawClassFieldInitialization::ClassBody(None) => { - ClassFieldInitialization::ClassBody(None) - } - RawClassFieldInitialization::ClassBody(Some(e)) => { - // If this field was created via a call to a dataclass field specifier, extract field flags from the call. - if let Some(dm) = metadata.dataclass_metadata() - && let Expr::Call(ExprCall { - node_index: _, - range: _, - func, - arguments, - }) = e - { - // We already type-checked this expression as part of computing the type for the ClassField, - // so we can ignore any errors encountered here. - let ignore_errors = self.error_swallower(); - let func_ty = self.expr_infer(func, &ignore_errors); - let func_kind = func_ty.callee_kind(); - if let Some(func_kind) = func_kind - && dm.field_specifiers.contains(&func_kind) - { - let flags = - self.dataclass_field_keywords(&func_ty, arguments, dm, &ignore_errors); - ClassFieldInitialization::ClassBody(Some(flags)) - } else { - ClassFieldInitialization::ClassBody(None) - } - } else { - ClassFieldInitialization::ClassBody(None) - } - } - RawClassFieldInitialization::Method(MethodThatSetsAttr { - instance_or_class: MethodSelfKind::Class, - .. - }) => ClassFieldInitialization::ClassBody(None), - RawClassFieldInitialization::Method(MethodThatSetsAttr { - instance_or_class: MethodSelfKind::Instance, - .. - }) - | RawClassFieldInitialization::Uninitialized - if magically_initialized => - { - ClassFieldInitialization::Magic - } - RawClassFieldInitialization::Method(MethodThatSetsAttr { - instance_or_class: MethodSelfKind::Instance, - .. - }) => ClassFieldInitialization::Method, - RawClassFieldInitialization::Uninitialized => ClassFieldInitialization::Uninitialized, - } - } - fn validate_direct_annotation( &self, annotation: &Annotation, From 309300c778a2cf595931f0ff8f27c3f6d371332a Mon Sep 17 00:00:00 2001 From: Steven Troxler Date: Sat, 22 Nov 2025 11:13:50 -0800 Subject: [PATCH 11/62] Inline magic initialization check into the match Summary: This is more straightforward to read, it was only used once. Reviewed By: grievejia Differential Revision: D87653408 fbshipit-source-id: c79c800d29e1268fa4abe6090b5111a47056f3ef --- pyrefly/lib/alt/class/class_field.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index 020a3928be..7ac323bbf7 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -1225,15 +1225,6 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } }; - let magically_initialized = { - // We consider fields to be always-initialized if it's defined within stub files. - // See https://github.com/python/typeshed/pull/13875 for reasoning. - class.module_path().is_interface() - // We consider fields to be always-initialized if it's annotated explicitly with `ClassVar`. - || direct_annotation - .as_ref() - .is_some_and(|annot| annot.has_qualifier(&Qualifier::ClassVar)) - }; let initialization = match initial_value { RawClassFieldInitialization::ClassBody(None) => { ClassFieldInitialization::ClassBody(None) @@ -1275,7 +1266,13 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { .. }) | RawClassFieldInitialization::Uninitialized - if magically_initialized => + // We consider fields to be always-initialized if it's defined within stub files. + // See https://github.com/python/typeshed/pull/13875 for reasoning. + if class.module_path().is_interface() + // We consider fields to be always-initialized if it's annotated explicitly with `ClassVar`. + || direct_annotation + .as_ref() + .is_some_and(|annot| annot.has_qualifier(&Qualifier::ClassVar)) => { ClassFieldInitialization::Magic } From e16f11ff2f2eccd356cb4e0bc1d9ad3086519296 Mon Sep 17 00:00:00 2001 From: Steven Troxler Date: Sat, 22 Nov 2025 11:13:50 -0800 Subject: [PATCH 12/62] Get rid of magic initialization for instance methods Summary: I'm pretty sure the logic here was not intended - it doesn't really make sense to use magic initialization (which implies class-level attribute access semantics) for any attribute defined in an instance method. Cleaning this up lets us move the magic initialization check to somewhere that makes much more sense to me - it's only relevant to the `Uninitialized` branch. Reviewed By: grievejia Differential Revision: D87653411 fbshipit-source-id: b1493f5af08a241e86e3179cc6c3d124cfe76d55 --- pyrefly/lib/alt/class/class_field.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index 7ac323bbf7..00c8809299 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -1264,23 +1264,21 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { RawClassFieldInitialization::Method(MethodThatSetsAttr { instance_or_class: MethodSelfKind::Instance, .. - }) - | RawClassFieldInitialization::Uninitialized + }) => ClassFieldInitialization::Method, + RawClassFieldInitialization::Uninitialized => { // We consider fields to be always-initialized if it's defined within stub files. // See https://github.com/python/typeshed/pull/13875 for reasoning. if class.module_path().is_interface() // We consider fields to be always-initialized if it's annotated explicitly with `ClassVar`. || direct_annotation .as_ref() - .is_some_and(|annot| annot.has_qualifier(&Qualifier::ClassVar)) => - { - ClassFieldInitialization::Magic + .is_some_and(|annot| annot.has_qualifier(&Qualifier::ClassVar)) + { + ClassFieldInitialization::Magic + } else { + ClassFieldInitialization::Uninitialized + } } - RawClassFieldInitialization::Method(MethodThatSetsAttr { - instance_or_class: MethodSelfKind::Instance, - .. - }) => ClassFieldInitialization::Method, - RawClassFieldInitialization::Uninitialized => ClassFieldInitialization::Uninitialized, }; if let Some(annotation) = direct_annotation.as_ref() { From ea4270ef2f717b592d2f81ddbc2ec31fbceba681 Mon Sep 17 00:00:00 2001 From: Steven Troxler Date: Sat, 22 Nov 2025 11:13:50 -0800 Subject: [PATCH 13/62] Trivial restructure of matching code Summary: I want to pull some logic into a helper, but the `if` clause is doing too much work right now; let's minimize the clause by moving the destructuring into the curly brackets. Reviewed By: grievejia Differential Revision: D87653416 fbshipit-source-id: 652ef6c791c8e565b22940c6ac438d6665929643 --- pyrefly/lib/alt/class/class_field.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index 00c8809299..17ff7978d0 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -1232,13 +1232,14 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { RawClassFieldInitialization::ClassBody(Some(e)) => { // If this field was created via a call to a dataclass field specifier, extract field flags from the call. if let Some(dm) = metadata.dataclass_metadata() - && let Expr::Call(ExprCall { + && let Expr::Call(call) = e + { + let ExprCall { node_index: _, range: _, func, arguments, - }) = e - { + } = call; // We already type-checked this expression as part of computing the type for the ClassField, // so we can ignore any errors encountered here. let ignore_errors = self.error_swallower(); From 9b549c99b4034ea6483d4ccd8f1fbdcd93d1b311 Mon Sep 17 00:00:00 2001 From: Steven Troxler Date: Sat, 22 Nov 2025 11:13:50 -0800 Subject: [PATCH 14/62] Extract `dataclass_field_initialization` code to a helper Summary: It's easier to skim the initialization block if we give this logic a nice name and move it out of the big match. Reviewed By: grievejia Differential Revision: D87653412 fbshipit-source-id: 8f24e532523accb3aa70e484630aa948a34fff62 --- pyrefly/lib/alt/class/class_field.rs | 49 ++++++++++++++++------------ 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index 17ff7978d0..f42566fdd5 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -1234,26 +1234,8 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { if let Some(dm) = metadata.dataclass_metadata() && let Expr::Call(call) = e { - let ExprCall { - node_index: _, - range: _, - func, - arguments, - } = call; - // We already type-checked this expression as part of computing the type for the ClassField, - // so we can ignore any errors encountered here. - let ignore_errors = self.error_swallower(); - let func_ty = self.expr_infer(func, &ignore_errors); - let func_kind = func_ty.callee_kind(); - if let Some(func_kind) = func_kind - && dm.field_specifiers.contains(&func_kind) - { - let flags = - self.dataclass_field_keywords(&func_ty, arguments, dm, &ignore_errors); - ClassFieldInitialization::ClassBody(Some(flags)) - } else { - ClassFieldInitialization::ClassBody(None) - } + let flags = self.compute_dataclass_field_initialization(call, dm); + ClassFieldInitialization::ClassBody(flags) } else { ClassFieldInitialization::ClassBody(None) } @@ -1649,6 +1631,33 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } } + /// Extract dataclass field keywords from a call expression if it's a dataclass field specifier. + fn compute_dataclass_field_initialization( + &self, + call: &ExprCall, + dm: &crate::alt::types::class_metadata::DataclassMetadata, + ) -> Option { + let ExprCall { + node_index: _, + range: _, + func, + arguments, + } = call; + // We already type-checked this expression as part of computing the type for the ClassField, + // so we can ignore any errors encountered here. + let ignore_errors = self.error_swallower(); + let func_ty = self.expr_infer(func, &ignore_errors); + let func_kind = func_ty.callee_kind(); + if let Some(func_kind) = func_kind + && dm.field_specifiers.contains(&func_kind) + { + let flags = self.dataclass_field_keywords(&func_ty, arguments, dm, &ignore_errors); + Some(flags) + } else { + None + } + } + fn validate_direct_annotation( &self, annotation: &Annotation, From f04479a481a7547a15e937fdf7d4ae1138860d7a Mon Sep 17 00:00:00 2001 From: Ron Mordechai Date: Sat, 22 Nov 2025 11:54:04 -0800 Subject: [PATCH 15/62] Vendor typify Summary: This turns JSON schema run Rust structs Differential Revision: D87559335 fbshipit-source-id: c13ca92bbfc5ce8ddf4999fe7d10b97513b8a6a1 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 18b0bd87ae..7101a44ff2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3376,9 +3376,9 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicode-ident" -version = "1.0.16" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-normalization" From c78f732530f089940c391a4fc514a27664885ddf Mon Sep 17 00:00:00 2001 From: Danny Yang Date: Sun, 23 Nov 2025 15:22:36 -0800 Subject: [PATCH 16/62] preserve quantified information when unpacking quantified type var tuple Summary: Unpacking a `tuple[*Ts]` hits some fallback code and turns the contents into `object` We fix this by introducing an `ElementOf[Ts]` type, which is similar to `Type::QuantifiedValue` for TypeVar but it represents an element of the TypeVarTuple, instead of the whole thing. So when we try to iterate some `tuple[*Ts]`, we produce an unspecified number of `ElementOf[Ts]`, and when we try to make an unbounded tuple with `ElementOf[Ts]` contents we actually create `tuple[*Ts]`. There are additional guards against unpacking the same type var tuple twice in a tuple expression or function call, since the lengths will not match up unless the typevartuple resolves to empty tuple. fixes https://github.com/facebook/pyrefly/issues/1268 Reviewed By: stroxler Differential Revision: D87694394 fbshipit-source-id: 19be3f7282d6e4dfec13e0df5523e2ade658e63a --- conformance/third_party/conformance.exp | 4 +- conformance/third_party/conformance.result | 2 +- crates/pyrefly_types/src/display.rs | 1 + crates/pyrefly_types/src/types.rs | 10 +++- pyrefly/lib/alt/attr.rs | 3 ++ pyrefly/lib/alt/callable.rs | 14 +++++- pyrefly/lib/alt/solve.rs | 54 +++++++++++++++------- pyrefly/lib/alt/unwrap.rs | 16 +++++-- pyrefly/lib/binding/binding.rs | 14 ++++++ pyrefly/lib/test/type_var_tuple.rs | 29 ++++++++++++ 10 files changed, 120 insertions(+), 27 deletions(-) diff --git a/conformance/third_party/conformance.exp b/conformance/third_party/conformance.exp index 6800c879e3..a4d85899fd 100644 --- a/conformance/third_party/conformance.exp +++ b/conformance/third_party/conformance.exp @@ -6832,8 +6832,8 @@ { "code": -2, "column": 12, - "concise_description": "Returned type `tuple[*tuple[object | T, ...], object | T]` is not assignable to declared return type `tuple[*Ts, T]`", - "description": "Returned type `tuple[*tuple[object | T, ...], object | T]` is not assignable to declared return type `tuple[*Ts, T]`", + "concise_description": "Returned type `tuple[*tuple[T | ElementOf[Ts], ...], T | ElementOf[Ts]]` is not assignable to declared return type `tuple[*Ts, T]`", + "description": "Returned type `tuple[*tuple[T | ElementOf[Ts], ...], T | ElementOf[Ts]]` is not assignable to declared return type `tuple[*Ts, T]`", "line": 56, "name": "bad-return", "severity": "error", diff --git a/conformance/third_party/conformance.result b/conformance/third_party/conformance.result index f60bfd831d..d1cfe7442e 100644 --- a/conformance/third_party/conformance.result +++ b/conformance/third_party/conformance.result @@ -189,7 +189,7 @@ ], "generics_typevartuple_callable.py": [], "generics_typevartuple_concat.py": [ - "Line 56: Unexpected errors ['Returned type `tuple[*tuple[object | T, ...], object | T]` is not assignable to declared return type `tuple[*Ts, T]`']" + "Line 56: Unexpected errors ['Returned type `tuple[*tuple[T | ElementOf[Ts], ...], T | ElementOf[Ts]]` is not assignable to declared return type `tuple[*Ts, T]`']" ], "generics_typevartuple_overloads.py": [], "generics_typevartuple_specialization.py": [ diff --git a/crates/pyrefly_types/src/display.rs b/crates/pyrefly_types/src/display.rs index 43d1edd524..30fd7b340f 100644 --- a/crates/pyrefly_types/src/display.rs +++ b/crates/pyrefly_types/src/display.rs @@ -602,6 +602,7 @@ impl<'a> TypeDisplayContext<'a> { Type::Var(var) => output.write_str(&format!("{var}")), Type::Quantified(var) => output.write_str(&format!("{var}")), Type::QuantifiedValue(var) => output.write_str(&format!("{var}")), + Type::ElementOfTypeVarTuple(var) => output.write_str(&format!("ElementOf[{var}]")), Type::Args(q) => { output.write_str("Args[")?; output.write_str(&format!("{q}"))?; diff --git a/crates/pyrefly_types/src/types.rs b/crates/pyrefly_types/src/types.rs index 563303878e..ae6514c6c6 100644 --- a/crates/pyrefly_types/src/types.rs +++ b/crates/pyrefly_types/src/types.rs @@ -662,6 +662,8 @@ pub enum Type { /// This is equivalent to Type::TypeVar/ParamSpec/TypeVarTuple as a value, but when used /// in a type annotation, it becomes Type::Quantified. QuantifiedValue(Box), + /// When we unpack a Type::Quantified TypeVarTuple, this is what we get + ElementOfTypeVarTuple(Box), TypeGuard(Box), TypeIs(Box), Unpack(Box), @@ -739,6 +741,7 @@ impl Visit for Type { Type::Var(x) => x.visit(f), Type::Quantified(x) => x.visit(f), Type::QuantifiedValue(x) => x.visit(f), + Type::ElementOfTypeVarTuple(x) => x.visit(f), Type::TypeGuard(x) => x.visit(f), Type::TypeIs(x) => x.visit(f), Type::Unpack(x) => x.visit(f), @@ -786,6 +789,7 @@ impl VisitMut for Type { Type::Var(x) => x.visit_mut(f), Type::Quantified(x) => x.visit_mut(f), Type::QuantifiedValue(x) => x.visit_mut(f), + Type::ElementOfTypeVarTuple(x) => x.visit_mut(f), Type::TypeGuard(x) => x.visit_mut(f), Type::TypeIs(x) => x.visit_mut(f), Type::Unpack(x) => x.visit_mut(f), @@ -887,7 +891,11 @@ impl Type { } pub fn unbounded_tuple(elt: Type) -> Self { - Type::Tuple(Tuple::Unbounded(Box::new(elt))) + if let Type::ElementOfTypeVarTuple(x) = elt { + Self::unpacked_tuple(Vec::new(), Type::Quantified(x), Vec::new()) + } else { + Type::Tuple(Tuple::Unbounded(Box::new(elt))) + } } pub fn unpacked_tuple(prefix: Vec, middle: Type, suffix: Vec) -> Self { diff --git a/pyrefly/lib/alt/attr.rs b/pyrefly/lib/alt/attr.rs index 533b22355a..7f1a0bfde4 100644 --- a/pyrefly/lib/alt/attr.rs +++ b/pyrefly/lib/alt/attr.rs @@ -1861,6 +1861,9 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { self.as_attribute_base1(x.1, &mut acc_fallback); acc.push(AttributeBase1::Intersect(acc_intersect, acc_fallback)); } + Type::ElementOfTypeVarTuple(_) => { + acc.push(AttributeBase1::ClassInstance(self.stdlib.object().clone())) + } // TODO: check to see which ones should have class representations Type::SpecialForm(_) | Type::Type(_) diff --git a/pyrefly/lib/alt/callable.rs b/pyrefly/lib/alt/callable.rs index b67955dde2..834e34ed64 100644 --- a/pyrefly/lib/alt/callable.rs +++ b/pyrefly/lib/alt/callable.rs @@ -259,7 +259,7 @@ impl<'a> CallArg<'a> { for x in iterables.iter() { match x { Iterable::FixedLen(xs) => fixed_lens.push(xs.len()), - Iterable::OfType(_) => {} + Iterable::OfType(_) | Iterable::OfTypeVarTuple(_) => {} } } if !fixed_lens.is_empty() @@ -636,6 +636,18 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { suffix, ), _ => { + let unpacked_variadic_args_count = middle + .iter() + .filter(|x| matches!(x, Type::ElementOfTypeVarTuple(_))) + .count(); + if unpacked_variadic_args_count > 1 { + error( + arg_errors, + range, + ErrorKind::BadArgumentType, + "Expected at most one unpacked variadic argument".to_owned(), + ); + } Type::unpacked_tuple(prefix, Type::unbounded_tuple(self.unions(middle)), suffix) } }; diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index 641ae7d923..a12cbdf4dd 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -213,6 +213,7 @@ impl TypeFormContext { pub enum Iterable { OfType(Type), FixedLen(Vec), + OfTypeVarTuple(Quantified), } impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { @@ -720,6 +721,12 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { vec![Iterable::FixedLen(elts.clone())] } Type::Tuple(Tuple::Concrete(elts)) => vec![Iterable::FixedLen(elts.clone())], + Type::Tuple(Tuple::Unbounded(box elt)) => vec![Iterable::OfType(elt.clone())], + Type::Tuple(Tuple::Unpacked(box (prefix, Type::Quantified(box q), suffix))) + if prefix.is_empty() && suffix.is_empty() && q.is_type_var_tuple() => + { + vec![Iterable::OfTypeVarTuple(q.clone())] + } Type::Var(v) if let Some(_guard) = self.recurse(*v) => { self.iterate(&self.solver().force_var(*v), range, errors, orig_context) } @@ -789,6 +796,9 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { match iterable { Iterable::OfType(t) => produced_types.push(t), Iterable::FixedLen(ts) => produced_types.extend(ts), + Iterable::OfTypeVarTuple(q) => { + produced_types.push(Type::ElementOfTypeVarTuple(Box::new(q))) + } } } self.unions(produced_types) @@ -1591,25 +1601,23 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { for iterable in iterables { match iterable { Iterable::OfType(_) => {} + Iterable::OfTypeVarTuple(_) => { + self.error( + errors, + *range, + ErrorInfo::Kind(ErrorKind::BadUnpacking), + format!( + "Cannot unpack {} (of unknown size) into {}", + iterable_ty, + expect.message(), + ), + ); + } Iterable::FixedLen(ts) => { let error = match expect { - SizeExpectation::Eq(n) => { - if ts.len() == *n { - None - } else { - match n { - 1 => Some(format!("{n} value")), - _ => Some(format!("{n} values")), - } - } - } - SizeExpectation::Ge(n) => { - if ts.len() >= *n { - None - } else { - Some(format!("{n}+ values")) - } - } + SizeExpectation::Eq(n) if ts.len() != *n => Some(expect.message()), + SizeExpectation::Ge(n) if ts.len() < *n => Some(expect.message()), + _ => None, }; match error { Some(expectation) => { @@ -3257,6 +3265,18 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { UnpackedPosition::Index(_) | UnpackedPosition::ReverseIndex(_) => ty, UnpackedPosition::Slice(_, _) => self.stdlib.list(ty).to_type(), }, + Iterable::OfTypeVarTuple(_) => { + // Type var tuples can resolve to anything so we fall back to object + let object_type = self.stdlib.object().clone().to_type(); + match pos { + UnpackedPosition::Index(_) | UnpackedPosition::ReverseIndex(_) => { + object_type + } + UnpackedPosition::Slice(_, _) => { + self.stdlib.list(object_type).to_type() + } + } + } Iterable::FixedLen(ts) => { match pos { UnpackedPosition::Index(i) | UnpackedPosition::ReverseIndex(i) => { diff --git a/pyrefly/lib/alt/unwrap.rs b/pyrefly/lib/alt/unwrap.rs index ed03dfb142..6ba1c06843 100644 --- a/pyrefly/lib/alt/unwrap.rs +++ b/pyrefly/lib/alt/unwrap.rs @@ -352,11 +352,17 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } Tuple::Unpacked(box (prefix, middle, suffix)) => { let mut elements = prefix; - if let Type::Tuple(Tuple::Unbounded(unbounded_middle)) = middle { - elements.push(*unbounded_middle); - } else { - // We can't figure out the middle, fall back to `object` - elements.push(self.stdlib.object().clone().to_type()) + match middle { + Type::Tuple(Tuple::Unbounded(unbounded_middle)) => { + elements.push(*unbounded_middle); + } + Type::Quantified(q) if q.is_type_var_tuple() => { + elements.push(Type::ElementOfTypeVarTuple(q)) + } + _ => { + // We can't figure out the middle, fall back to `object` + elements.push(self.stdlib.object().clone().to_type()) + } } elements.extend(suffix); self.stdlib.tuple(self.unions(elements)) diff --git a/pyrefly/lib/binding/binding.rs b/pyrefly/lib/binding/binding.rs index da8c9edb3a..12651316cb 100644 --- a/pyrefly/lib/binding/binding.rs +++ b/pyrefly/lib/binding/binding.rs @@ -1000,6 +1000,20 @@ pub enum SizeExpectation { Ge(usize), } +impl SizeExpectation { + pub fn message(&self) -> String { + match self { + SizeExpectation::Eq(n) => match n { + 1 => format!("{n} value"), + _ => format!("{n} values"), + }, + SizeExpectation::Ge(n) => { + format!("{n}+ values") + } + } + } +} + #[derive(Clone, Debug)] pub enum RaisedException { WithoutCause(Expr), diff --git a/pyrefly/lib/test/type_var_tuple.rs b/pyrefly/lib/test/type_var_tuple.rs index cf2931434e..31d395e27f 100644 --- a/pyrefly/lib/test/type_var_tuple.rs +++ b/pyrefly/lib/test/type_var_tuple.rs @@ -152,12 +152,41 @@ class A: "#, ); +testcase!( + test_type_var_tuple_unpack_quantified, + r#" +from typing import Callable + +def test[*Ts, T](f: Callable[[*Ts], T], t: tuple[*Ts], *args: *Ts): + # we can unpack a quantified type var tuple if it matches the expected type exactly + x: tuple[*Ts] = (*args,) + f(*args) + x = t + x = (*t,) + + # This error message could be improved + x = (*args, *args) # E: `tuple[ElementOf[Ts] | Unknown, ...]` is not assignable to variable `x` with type `tuple[*Ts]` + x = (*args, 1) # E: `tuple[*Ts, Literal[1]]` is not assignable to variable `x` with type `tuple[*Ts]` + x = (1, *args) # E: `tuple[Literal[1], *Ts]` is not assignable to variable `x` with type `tuple[*Ts]` + x = (*t, *t) # E: `tuple[ElementOf[Ts] | Unknown, ...]` is not assignable to variable `x` with type `tuple[*Ts]` + x = (*t, 1) # E: `tuple[*Ts, Literal[1]]` is not assignable to variable `x` with type `tuple[*Ts]` + x = (1, *t) # E: `tuple[Literal[1], *Ts]` is not assignable to variable `x` with type `tuple[*Ts]` + f(*args, *args) # E: Expected at most one unpacked variadic argument + f(1, *args) # E: Unpacked argument `tuple[Literal[1], *Ts]` is not assignable to varargs type `tuple[*Ts]` + f(*args, 1) # E: Unpacked argument `tuple[*Ts, Literal[1]]` is not assignable to varargs type `tuple[*Ts]` + f(*t, *t) # E: Expected at most one unpacked variadic argument + f(1, *t) # E: Unpacked argument `tuple[Literal[1], *Ts]` is not assignable to varargs type `tuple[*Ts]` + f(*t, 1) # E: Unpacked argument `tuple[*Ts, Literal[1]]` is not assignable to varargs type `tuple[*Ts]` +"#, +); + testcase!( test_type_var_tuple_callable_resolves_to_empty, r#" from typing import Callable, assert_type def test[*Ts, T](f: Callable[[*Ts], T], *args: *Ts) -> tuple[*Ts]: + x: T = f(*args) return (*args,) assert_type(test(lambda: 1), tuple[()]) From 005801cb57265fb5403701e59a889fe7c232a93e Mon Sep 17 00:00:00 2001 From: Rebecca Chen Date: Mon, 24 Nov 2025 01:17:43 -0800 Subject: [PATCH 17/62] Add more info to an `unreachable!` message Summary: Adds more info so we have a hope of debugging if we hit this `unreachable!`. For https://github.com/facebook/pyrefly/issues/1659. Reviewed By: ndmitchell Differential Revision: D87749551 fbshipit-source-id: 251cebf1199ebdf2f31658bfa486550a0887f5fe --- pyrefly/lib/alt/solve.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index a12cbdf4dd..c6ca567ce1 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -1363,12 +1363,15 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { pub fn scoped_type_params(&self, x: Option<&TypeParams>) -> Vec { match x { Some(x) => { - fn get_quantified(t: &Type) -> Quantified { - match t { - Type::QuantifiedValue(q) => (**q).clone(), - _ => unreachable!(), - } - } + let get_quantified = |t: &Type| match t { + Type::QuantifiedValue(q) => (**q).clone(), + _ => unreachable!( + "{}:{:?}: Expected a QuantifiedValue, got {}", + self.module().path().as_path().display(), + x.range(), + t + ), + }; let mut params = Vec::new(); for raw_param in x.type_params.iter() { let name = raw_param.name(); From 59b94ca9bb250e4c75933098f94e820c2bb67f19 Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 06:27:43 -0800 Subject: [PATCH 18/62] Add OutputWithLocations struct. Summary: ## Stack Summary This stack introduces a new struct called OutputWithLocations that will be used along with fmt_helper_generic in order to make certain parts of inlay hints clickable. The idea here is that now instead of writing parts of the type directly to a formatter, there is a generic TypeOutput that will be taken in can handle the collection of parts itself. In this case, we will be using an OutputWithLocations in order to collect the parts of the type and also get the location of the types definition if it is relevant. The rest of this stack will handle the actual implementation of this logic. ## Future work There are a few things that currently are known limitations that will be added as follow ups: 1. Currently tuple types will not be clickable. For example if you have `tuple[int]`, the `int` portion of the type will be clickable but the `tuple` portion will not be. A test has been added that covers this case. 2. Types coming from typing.py will not be clickable. For example, the type `Literal[1]` will not be clickable. This is something that will be addressed in a followup. A test has been added which covers this case. Introduces a new OutputWithLocations struct that implements TypeOutput to capture formatted type strings along with their source code locations. This struct collects type parts as a vector of (String, Option) tuples, enabling clickable type references in IDE features. Also exposes fmt_helper_generic as public to support the new location-aware formatting infrastructure. Adds the OutputWithLocations struct that implements the TypeOutput trait. This struct stores type parts as a vector of (String, Option) tuples, allowing us to capture both the formatted type string and its source location. The struct provides a new() constructor that takes a TypeDisplayContext and a parts() getter to access the collected parts. This is the first step in enabling clickable type references in IDE features. Reviewed By: ndmitchell Differential Revision: D87708533 fbshipit-source-id: 09d9c705974ff373a291d43880ffa752c198825b --- crates/pyrefly_types/src/type_output.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index 8ef5c327b5..a125515ec4 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -7,6 +7,7 @@ use std::fmt; +use pyrefly_python::module::TextRangeWithModule; use pyrefly_python::qname::QName; use crate::display::TypeDisplayContext; @@ -59,3 +60,14 @@ impl<'a, 'b, 'f> TypeOutput for DisplayOutput<'a, 'b, 'f> { write!(self.formatter, "{}", self.context.display(ty)) } } + +/// This struct is used to collect the type to be displayed as a vector. Each element +/// in the vector will be tuple of (String, Option). +/// The String the actual part of the string that will be displayed. When displaying the type +/// each of these will be concatenated to create the final type. +/// The second element of the vector is an optional location. For any part that do have +/// a location this will be included. For separators like '|', '[', etc. this will be None. +pub struct OutputWithLocations<'a> { + parts: Vec<(String, Option)>, + context: &'a TypeDisplayContext<'a>, +} From d0f3220cb20d956710f8af788fb9b564198ead37 Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 06:27:43 -0800 Subject: [PATCH 19/62] Add mock implementation of OutputWithLocations Summary: Provides stub implementations of all TypeOutput trait methods (write_str, write_qname, write_lit, write_targs, write_type) for the OutputWithLocations struct. Each method currently returns Ok(()) without any actual implementation. This allows the code to compile and establishes the structure needed for the subsequent commits that will implement each method properly with location tracking. Reviewed By: ndmitchell Differential Revision: D87708536 fbshipit-source-id: 532ed2c91195fe3aba7f47f1ede495f0ee03806e --- crates/pyrefly_types/src/type_output.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index a125515ec4..b094484834 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -68,6 +68,30 @@ impl<'a, 'b, 'f> TypeOutput for DisplayOutput<'a, 'b, 'f> { /// The second element of the vector is an optional location. For any part that do have /// a location this will be included. For separators like '|', '[', etc. this will be None. pub struct OutputWithLocations<'a> { + #[expect(dead_code)] parts: Vec<(String, Option)>, + #[expect(dead_code)] context: &'a TypeDisplayContext<'a>, } + +impl TypeOutput for OutputWithLocations<'_> { + fn write_str(&mut self, _s: &str) -> fmt::Result { + Ok(()) + } + + fn write_qname(&mut self, _qname: &QName) -> fmt::Result { + Ok(()) + } + + fn write_lit(&mut self, _lit: &Lit) -> fmt::Result { + Ok(()) + } + + fn write_targs(&mut self, _targs: &TArgs) -> fmt::Result { + Ok(()) + } + + fn write_type(&mut self, _ty: &Type) -> fmt::Result { + Ok(()) + } +} From 7081e244cc4c1f35262ebc9775de5f87a31eec95 Mon Sep 17 00:00:00 2001 From: generatedunixname89002005307016 Date: Mon, 24 Nov 2025 07:06:33 -0800 Subject: [PATCH 20/62] Update pyrefly version] Reviewed By: javabster Differential Revision: D87754773 fbshipit-source-id: 8c0f58e5b9e40be3916a4c74137a9ab4faf37415 --- Cargo.lock | 18 +++++++++--------- crates/pyrefly_build/Cargo.toml | 2 +- crates/pyrefly_bundled/Cargo.toml | 2 +- crates/pyrefly_config/Cargo.toml | 2 +- crates/pyrefly_derive/Cargo.toml | 2 +- crates/pyrefly_python/Cargo.toml | 2 +- crates/pyrefly_types/Cargo.toml | 2 +- crates/pyrefly_util/Cargo.toml | 2 +- crates/tsp_types/Cargo.toml | 2 +- pyrefly/Cargo.toml | 2 +- version.bzl | 2 +- 11 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7101a44ff2..bccfe7fd8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2022,7 +2022,7 @@ dependencies = [ [[package]] name = "pyrefly" -version = "0.42.3" +version = "0.43.0" dependencies = [ "anstream", "anyhow", @@ -2075,7 +2075,7 @@ dependencies = [ [[package]] name = "pyrefly_build" -version = "0.42.3" +version = "0.43.0" dependencies = [ "anyhow", "dupe", @@ -2093,7 +2093,7 @@ dependencies = [ [[package]] name = "pyrefly_bundled" -version = "0.42.3" +version = "0.43.0" dependencies = [ "anyhow", "sha2", @@ -2104,7 +2104,7 @@ dependencies = [ [[package]] name = "pyrefly_config" -version = "0.42.3" +version = "0.43.0" dependencies = [ "anyhow", "clap", @@ -2139,7 +2139,7 @@ dependencies = [ [[package]] name = "pyrefly_derive" -version = "0.42.3" +version = "0.43.0" dependencies = [ "proc-macro2", "quote", @@ -2148,7 +2148,7 @@ dependencies = [ [[package]] name = "pyrefly_python" -version = "0.42.3" +version = "0.43.0" dependencies = [ "anyhow", "clap", @@ -2175,7 +2175,7 @@ dependencies = [ [[package]] name = "pyrefly_types" -version = "0.42.3" +version = "0.43.0" dependencies = [ "compact_str 0.8.0", "dupe", @@ -2195,7 +2195,7 @@ dependencies = [ [[package]] name = "pyrefly_util" -version = "0.42.3" +version = "0.43.0" dependencies = [ "anstream", "anyhow", @@ -3354,7 +3354,7 @@ dependencies = [ [[package]] name = "tsp_types" -version = "0.42.3" +version = "0.43.0" dependencies = [ "lsp-server", "lsp-types", diff --git a/crates/pyrefly_build/Cargo.toml b/crates/pyrefly_build/Cargo.toml index 6ba874281d..ebe9561212 100644 --- a/crates/pyrefly_build/Cargo.toml +++ b/crates/pyrefly_build/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_build" -version = "0.42.3" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_bundled/Cargo.toml b/crates/pyrefly_bundled/Cargo.toml index aa4921f874..3750f472cb 100644 --- a/crates/pyrefly_bundled/Cargo.toml +++ b/crates/pyrefly_bundled/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_bundled" -version = "0.42.3" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_config/Cargo.toml b/crates/pyrefly_config/Cargo.toml index aad9956977..46194d08d8 100644 --- a/crates/pyrefly_config/Cargo.toml +++ b/crates/pyrefly_config/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_config" -version = "0.42.3" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_derive/Cargo.toml b/crates/pyrefly_derive/Cargo.toml index 7bcacbdd74..ef7b5abc6b 100644 --- a/crates/pyrefly_derive/Cargo.toml +++ b/crates/pyrefly_derive/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_derive" -version = "0.42.3" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_python/Cargo.toml b/crates/pyrefly_python/Cargo.toml index 47557b1229..ffde10c27a 100644 --- a/crates/pyrefly_python/Cargo.toml +++ b/crates/pyrefly_python/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_python" -version = "0.42.3" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_types/Cargo.toml b/crates/pyrefly_types/Cargo.toml index a3012f9610..55fcacc207 100644 --- a/crates/pyrefly_types/Cargo.toml +++ b/crates/pyrefly_types/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_types" -version = "0.42.3" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_util/Cargo.toml b/crates/pyrefly_util/Cargo.toml index d76b789e0b..d616690b3e 100644 --- a/crates/pyrefly_util/Cargo.toml +++ b/crates/pyrefly_util/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_util" -version = "0.42.3" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/tsp_types/Cargo.toml b/crates/tsp_types/Cargo.toml index 691d67d9ac..8062fd84de 100644 --- a/crates/tsp_types/Cargo.toml +++ b/crates/tsp_types/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "tsp_types" -version = "0.42.3" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/pyrefly/Cargo.toml b/pyrefly/Cargo.toml index 01eeb5e11c..d8cc9bd0a6 100644 --- a/pyrefly/Cargo.toml +++ b/pyrefly/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly" -version = "0.42.3" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/version.bzl b/version.bzl index 44111de8c4..746d759eda 100644 --- a/version.bzl +++ b/version.bzl @@ -13,4 +13,4 @@ # * After updating the version, run `arc autocargo -p pyrefly` to regenerate `Cargo.toml` # and put the resulting diff up for review. Once the diff lands, the new version should be # available on PyPI within a few hours. -VERSION = "0.42.3" +VERSION = "0.43.0" From 7f4cc2443bf86e355997b1bbb65e98c289d83b34 Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 08:15:17 -0800 Subject: [PATCH 21/62] Implement write_str Summary: Implements the write_str method for OutputWithLocations to store plain string parts without location information. This method pushes a tuple of (s.to_owned(), None) to the parts vector, where None indicates no source location is associated with this text. This is used for structural elements like brackets, commas, and separators in type formatting. Includes comprehensive tests verifying that write_str correctly stores strings with no location data. Reviewed By: ndmitchell Differential Revision: D87711995 fbshipit-source-id: a30b708de52ce43610a7778e0b38a56c408ff172 --- crates/pyrefly_types/src/type_output.rs | 44 +++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index b094484834..de2e124c8c 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -68,14 +68,27 @@ impl<'a, 'b, 'f> TypeOutput for DisplayOutput<'a, 'b, 'f> { /// The second element of the vector is an optional location. For any part that do have /// a location this will be included. For separators like '|', '[', etc. this will be None. pub struct OutputWithLocations<'a> { - #[expect(dead_code)] parts: Vec<(String, Option)>, #[expect(dead_code)] context: &'a TypeDisplayContext<'a>, } +impl<'a> OutputWithLocations<'a> { + pub fn new(context: &'a TypeDisplayContext<'a>) -> Self { + Self { + parts: Vec::new(), + context, + } + } + + pub fn parts(&self) -> &[(String, Option)] { + &self.parts + } +} + impl TypeOutput for OutputWithLocations<'_> { - fn write_str(&mut self, _s: &str) -> fmt::Result { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.parts.push((s.to_owned(), None)); Ok(()) } @@ -95,3 +108,30 @@ impl TypeOutput for OutputWithLocations<'_> { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_output_with_locations_write_str() { + let context = TypeDisplayContext::default(); + let mut output = OutputWithLocations::new(&context); + + assert_eq!(output.parts().len(), 0); + + output.write_str("hello").unwrap(); + assert_eq!(output.parts().len(), 1); + assert_eq!(output.parts()[0].0, "hello"); + assert!(output.parts()[0].1.is_none()); + + output.write_str(" world").unwrap(); + assert_eq!(output.parts().len(), 2); + assert_eq!(output.parts()[1].0, " world"); + assert!(output.parts()[1].1.is_none()); + + let parts = output.parts(); + assert_eq!(parts[0].0, "hello"); + assert_eq!(parts[1].0, " world"); + } +} From 4a2fa2afad902cdb492d347e75d987508491f953 Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 08:15:17 -0800 Subject: [PATCH 22/62] Implement write_qname Summary: Implements the write_qname method for OutputWithLocations to capture qualified names with their source locations. Creates a TextRangeWithModule from the qname's module and range, then pushes (qname.id().to_string(), Some(location)) to the parts vector. This enables clickable class names and other qualified identifiers in type hints. Includes tests verifying that qname location information (module and text range) is correctly captured and stored. Reviewed By: ndmitchell Differential Revision: D87711996 fbshipit-source-id: ffe6022d6a0f861f25cf8470b709b7adf4effeb0 --- crates/pyrefly_types/src/type_output.rs | 49 ++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index de2e124c8c..2a4d13a066 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -92,7 +92,9 @@ impl TypeOutput for OutputWithLocations<'_> { Ok(()) } - fn write_qname(&mut self, _qname: &QName) -> fmt::Result { + fn write_qname(&mut self, qname: &QName) -> fmt::Result { + let location = TextRangeWithModule::new(qname.module().clone(), qname.range()); + self.parts.push((qname.id().to_string(), Some(location))); Ok(()) } @@ -111,6 +113,19 @@ impl TypeOutput for OutputWithLocations<'_> { #[cfg(test)] mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use pyrefly_python::module::Module; + use pyrefly_python::module_name::ModuleName; + use pyrefly_python::module_path::ModulePath; + use pyrefly_python::nesting_context::NestingContext; + use pyrefly_python::qname::QName; + use ruff_python_ast::Identifier; + use ruff_python_ast::name::Name; + use ruff_text_size::TextRange; + use ruff_text_size::TextSize; + use super::*; #[test] @@ -134,4 +149,36 @@ mod tests { assert_eq!(parts[0].0, "hello"); assert_eq!(parts[1].0, " world"); } + + #[test] + fn test_output_with_locations_write_qname() { + let context = TypeDisplayContext::default(); + let mut output = OutputWithLocations::new(&context); + + let module = Module::new( + ModuleName::from_str("test_module"), + ModulePath::filesystem(PathBuf::from("test_module.py")), + Arc::new("def foo(): pass".to_owned()), + ); + + let identifier = Identifier::new( + Name::new("MyClass"), + TextRange::new(TextSize::new(4), TextSize::new(11)), + ); + + let qname = QName::new(identifier, NestingContext::toplevel(), module.clone()); + output.write_qname(&qname).unwrap(); + + assert_eq!(output.parts().len(), 1); + let (name_str, location) = &output.parts()[0]; + assert_eq!(name_str, "MyClass"); + + assert!(location.is_some()); + let loc = location.as_ref().unwrap(); + assert_eq!( + loc.range, + TextRange::new(TextSize::new(4), TextSize::new(11)) + ); + assert_eq!(loc.module.name(), ModuleName::from_str("test_module")); + } } From ed7a356bb183455ea230cf2be0a312a02dabb33b Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 08:15:17 -0800 Subject: [PATCH 23/62] Implement write_lit Summary: Implements the write_lit method for OutputWithLocations to handle literal values with conditional location tracking. For Enum literals (Lit::Enum), extracts the location from the enum's class qname since these have meaningful source locations. For other literal types (Str, Int, Bool, Bytes), stores None as they don't have associated source definitions. Formats all literals using their Display implementation and pushes the appropriate (formatted, location) tuple to parts. Includes comprehensive tests covering both enum literals (with locations) and simple literals (without locations). Reviewed By: ndmitchell Differential Revision: D87712002 fbshipit-source-id: 3b22dcf4ded29be075ad8e8e61a8cd9226de0955 --- crates/pyrefly_types/src/type_output.rs | 80 ++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index 2a4d13a066..d2db14548a 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -98,7 +98,21 @@ impl TypeOutput for OutputWithLocations<'_> { Ok(()) } - fn write_lit(&mut self, _lit: &Lit) -> fmt::Result { + fn write_lit(&mut self, lit: &Lit) -> fmt::Result { + // Format the literal and extract location if it's an Enum literal + let formatted = lit.to_string(); + let location = match lit { + Lit::Enum(lit_enum) => { + // Enum literals have a class with a qname that has location info + let qname = lit_enum.class.qname(); + Some(TextRangeWithModule::new( + qname.module().clone(), + qname.range(), + )) + } + _ => None, + }; + self.parts.push((formatted, location)); Ok(()) } @@ -127,6 +141,28 @@ mod tests { use ruff_text_size::TextSize; use super::*; + use crate::class::Class; + use crate::class::ClassDefIndex; + use crate::class::ClassType; + use crate::literal::LitEnum; + use crate::types::TArgs; + + fn fake_class(name: &str, module: &str, range: u32) -> Class { + let mi = Module::new( + ModuleName::from_str(module), + ModulePath::filesystem(PathBuf::from(module)), + Arc::new("1234567890".to_owned()), + ); + + Class::new( + ClassDefIndex(0), + Identifier::new(Name::new(name), TextRange::empty(TextSize::new(range))), + NestingContext::toplevel(), + mi, + None, + starlark_map::small_map::SmallMap::new(), + ) + } #[test] fn test_output_with_locations_write_str() { @@ -181,4 +217,46 @@ mod tests { ); assert_eq!(loc.module.name(), ModuleName::from_str("test_module")); } + + #[test] + fn test_output_with_locations_write_lit_non_enum() { + let context = TypeDisplayContext::default(); + let mut output = OutputWithLocations::new(&context); + + // Test with a string literal - should have no location + let str_lit = Lit::Str("hello".into()); + output.write_lit(&str_lit).unwrap(); + + assert_eq!(output.parts().len(), 1); + assert_eq!(output.parts()[0].0, "'hello'"); + assert!(output.parts()[0].1.is_none()); + } + + #[test] + fn test_output_with_locations_write_lit_enum() { + let context = TypeDisplayContext::default(); + let mut output = OutputWithLocations::new(&context); + + // Create an Enum literal with location information + let enum_class = ClassType::new(fake_class("Color", "colors", 10), TArgs::default()); + + let enum_lit = Lit::Enum(Box::new(LitEnum { + class: enum_class, + member: Name::new("RED"), + ty: Type::any_implicit(), + })); + + output.write_lit(&enum_lit).unwrap(); + + assert_eq!(output.parts().len(), 1); + let (formatted, location) = &output.parts()[0]; + + assert_eq!(formatted, "Color.RED"); + + // Verify the location was captured from the enum's class qname + assert!(location.is_some()); + let loc = location.as_ref().unwrap(); + assert_eq!(loc.range, TextRange::empty(TextSize::new(10))); + assert_eq!(loc.module.name(), ModuleName::from_str("colors")); + } } From 4d42f7bd16f5f6e7046b7e558a44ebc4b042d463 Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 08:15:17 -0800 Subject: [PATCH 24/62] Implement write_targs Summary: Implements the write_targs method for OutputWithLocations to handle type arguments in generic types. For non-empty type arguments, writes opening bracket "[", then iterates through each type argument calling write_type (which will recursively capture locations), inserting ", " separators between arguments, and finally writes closing bracket "]". This ensures that each type in a generic like List[int] gets its own location. Empty type arguments produce no output. Includes tests verifying correct bracket structure, comma separation, and integration with write_type calls. Reviewed By: ndmitchell Differential Revision: D87712004 fbshipit-source-id: d082709ff1a57b24eba75fabcf73b57129024672 --- crates/pyrefly_types/src/type_output.rs | 79 ++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index d2db14548a..ba333d5904 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -116,7 +116,20 @@ impl TypeOutput for OutputWithLocations<'_> { Ok(()) } - fn write_targs(&mut self, _targs: &TArgs) -> fmt::Result { + fn write_targs(&mut self, targs: &TArgs) -> fmt::Result { + // Write each type argument separately with its own location + // This ensures that each type in a union (e.g., int | str) gets its own + // clickable part with a link to its definition + if !targs.is_empty() { + self.write_str("[")?; + for (i, ty) in targs.as_slice().iter().enumerate() { + if i > 0 { + self.write_str(", ")?; + } + self.write_type(ty)?; + } + self.write_str("]")?; + } Ok(()) } @@ -145,7 +158,13 @@ mod tests { use crate::class::ClassDefIndex; use crate::class::ClassType; use crate::literal::LitEnum; + use crate::quantified::Quantified; + use crate::quantified::QuantifiedKind; + use crate::type_var::PreInferenceVariance; + use crate::type_var::Restriction; use crate::types::TArgs; + use crate::types::TParam; + use crate::types::TParams; fn fake_class(name: &str, module: &str, range: u32) -> Class { let mi = Module::new( @@ -259,4 +278,62 @@ mod tests { assert_eq!(loc.range, TextRange::empty(TextSize::new(10))); assert_eq!(loc.module.name(), ModuleName::from_str("colors")); } + + #[test] + fn test_output_with_locations_write_targs_multiple() { + let context = TypeDisplayContext::default(); + let mut output = OutputWithLocations::new(&context); + + // Create TArgs with multiple type arguments + let tparam1 = TParam { + quantified: Quantified::new( + pyrefly_util::uniques::UniqueFactory::new().fresh(), + Name::new("T"), + QuantifiedKind::TypeVar, + None, + Restriction::Unrestricted, + ), + variance: PreInferenceVariance::PInvariant, + }; + let tparam2 = TParam { + quantified: Quantified::new( + pyrefly_util::uniques::UniqueFactory::new().fresh(), + Name::new("U"), + QuantifiedKind::TypeVar, + None, + Restriction::Unrestricted, + ), + variance: PreInferenceVariance::PInvariant, + }; + let tparam3 = TParam { + quantified: Quantified::new( + pyrefly_util::uniques::UniqueFactory::new().fresh(), + Name::new("V"), + QuantifiedKind::TypeVar, + None, + Restriction::Unrestricted, + ), + variance: PreInferenceVariance::PInvariant, + }; + + let tparams = Arc::new(TParams::new(vec![tparam1, tparam2, tparam3])); + let targs = TArgs::new( + tparams, + vec![Type::None, Type::LiteralString, Type::any_explicit()], + ); + + output.write_targs(&targs).unwrap(); + + // Should have "[", ", ", ", ", and "]" + // write_type is stubbed, so types themselves don't appear + assert_eq!(output.parts().len(), 4); + assert_eq!(output.parts()[0].0, "["); + assert!(output.parts()[0].1.is_none()); + assert_eq!(output.parts()[1].0, ", "); + assert!(output.parts()[1].1.is_none()); + assert_eq!(output.parts()[2].0, ", "); + assert!(output.parts()[2].1.is_none()); + assert_eq!(output.parts()[3].0, "]"); + assert!(output.parts()[3].1.is_none()); + } } From 6db8cdf608b0f40dbf1eb7dae2c029451b1c5754 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 24 Nov 2025 09:13:06 -0800 Subject: [PATCH 25/62] fix parsing of multi-line parameter descriptions in docstrings #1588 (#1629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: fix https://github.com/facebook/pyrefly/issues/1588 Added a docstring parsing pipeline that normalizes raw literals, extracts `multi-line :param …:` and `Args: sections`, and exposes the results via parse_parameter_documentation, plus unit tests covering both styles. This provides the richer parameter metadata requested in the issue. An extended signature helps to capture the callee range, look up the associated docstring, and parse parameter descriptions and surface them through ParameterInformation documentation, so hover/signature hints show the full multi-line text Added an integration test to ensure signature helps surface the parsed docs and kept the helper coverage in sync Pull Request resolved: https://github.com/facebook/pyrefly/pull/1629 Reviewed By: yangdanny97 Differential Revision: D87673056 Pulled By: kinto0 fbshipit-source-id: 6bea24261d9c27647b629272995d76ecdd176cf8 --- crates/pyrefly_python/src/docstring.rs | 333 ++++++++++++++++++++++--- pyrefly/lib/lsp/wasm/hover.rs | 119 ++++++++- pyrefly/lib/lsp/wasm/signature_help.rs | 75 +++++- pyrefly/lib/state/lsp.rs | 29 ++- pyrefly/lib/test/lsp/hover.rs | 26 ++ pyrefly/lib/test/lsp/signature_help.rs | 45 ++++ 6 files changed, 569 insertions(+), 58 deletions(-) diff --git a/crates/pyrefly_python/src/docstring.rs b/crates/pyrefly_python/src/docstring.rs index 429f8bbd07..0ff757ae72 100644 --- a/crates/pyrefly_python/src/docstring.rs +++ b/crates/pyrefly_python/src/docstring.rs @@ -6,6 +6,7 @@ */ use std::cmp::min; +use std::collections::HashMap; use ruff_python_ast::Expr; use ruff_python_ast::Stmt; @@ -14,6 +15,29 @@ use ruff_text_size::TextRange; use crate::module::Module; +const STRING_LITERAL_PATTERNS: [(&str, &str); 8] = [ + ("\"\"\"", "\"\"\""), // Multiline double quotes + ("\'\'\'", "\'\'\'"), // Multiline single quotes + ("r\"\"\"", "\"\"\""), // Raw multiline double quotes + ("r\'\'\'", "\'\'\'"), // Raw multiline single quotes + ("\'", "\'"), // Single quotes + ("r\'", "\'"), // Raw single quotes + ("\"", "\""), // Double quotes + ("r\"", "\""), // Raw double quotes +]; + +fn strip_literal_quotes<'a>(mut text: &'a str) -> &'a str { + for (prefix, suffix) in STRING_LITERAL_PATTERNS { + if let Some(x) = text.strip_prefix(prefix) + && let Some(x) = x.strip_suffix(suffix) + { + text = x; + break; + } + } + text +} + #[derive(Debug, Clone)] pub struct Docstring(pub TextRange, pub Module); @@ -30,44 +54,10 @@ impl Docstring { /// Clean a string literal ("""...""") and turn it into a docstring. pub fn clean(docstring: &str) -> String { - let result = docstring.replace("\r", "").replace("\t", " "); - - // Remove any string literal prefixes and suffixes - let patterns = [ - ("\"\"\"", "\"\"\""), // Multiline double quotes - ("\'\'\'", "\'\'\'"), // Multiline single quotes - ("r\"\"\"", "\"\"\""), // Raw multiline double quotes - ("\'", "\'"), // Single quotes - ("r\'", "\'"), // Raw single quotes - ("\"", "\""), // Double quotes - ("r\"", "\""), // Raw double quotes - ]; - - let mut result = result.as_str(); - for (prefix, suffix) in patterns { - if let Some(x) = result.strip_prefix(prefix) - && let Some(x) = x.strip_suffix(suffix) - { - result = x; - break; // Stop after first match to avoid over-trimming - } - } - let result = result.replace("\r", "").replace("\t", " "); + let result = normalize_literal(docstring); // Remove the shortest amount of whitespace from the beginning of each line - let min_indent = result - .lines() - .skip(1) - .flat_map(|line| { - let spaces = line.bytes().take_while(|&c| c == b' ').count(); - if spaces == line.len() { - None - } else { - Some(spaces) - } - }) - .min() - .unwrap_or(0); + let min_indent = minimal_indentation(result.lines().skip(1)); result .lines() @@ -108,9 +98,256 @@ impl Docstring { } } +fn normalize_literal(docstring: &str) -> String { + let normalized = docstring.replace("\r", "").replace("\t", " "); + let stripped = strip_literal_quotes(&normalized); + stripped.replace("\r", "").replace("\t", " ") +} + +fn dedented_lines_for_parsing(docstring: &str) -> Vec { + let stripped = normalize_literal(docstring); + let stripped = stripped.trim_matches('\n'); + if stripped.is_empty() { + return Vec::new(); + } + + let lines: Vec<&str> = stripped.lines().collect(); + if lines.is_empty() { + return Vec::new(); + } + + let min_indent = minimal_indentation(lines.iter().copied()); + + lines + .into_iter() + .map(|line| { + if line.trim_end().is_empty() { + String::new() + } else { + let start = min_indent.min(line.len()); + line[start..].to_owned() + } + }) + .collect() +} + +fn leading_space_count(line: &str) -> usize { + line.as_bytes().iter().take_while(|c| **c == b' ').count() +} + +fn minimal_indentation<'a, I>(lines: I) -> usize +where + I: Iterator, +{ + lines + .filter_map(|line| { + let trimmed = line.trim_end(); + if trimmed.is_empty() { + None + } else { + Some(leading_space_count(line)) + } + }) + .min() + .unwrap_or(0) +} + +/// Persist the documentation collected so far for the current parameter. +fn commit_parameter_doc( + current_param: &mut Option, + current_lines: &mut Vec, + docs: &mut HashMap, +) { + if let Some(name) = current_param.take() { + let content = current_lines.join("\n").trim().to_owned(); + current_lines.clear(); + if !content.is_empty() { + docs.entry(name).or_insert(content); + } + } +} + +/// Parse [`Sphinx`](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html) +/// style `:param foo: description` blocks into a map of parameter docs. +fn parse_sphinx_params(lines: &[String], docs: &mut HashMap) { + let mut current_param = None; + let mut current_lines = Vec::new(); + let mut base_indent = 0usize; + + for line in lines { + let trimmed = line.trim_start(); + if trimmed.starts_with(":param") { + commit_parameter_doc(&mut current_param, &mut current_lines, docs); + + let rest = trimmed.trim_start_matches(":param").trim_start(); + let (name_part, desc_part) = match rest.split_once(':') { + Some(parts) => parts, + None => continue, + }; + let name = name_part + .split_whitespace() + .last() + .unwrap_or("") + .trim_matches(',') + .trim_end_matches(':') + .trim_start_matches('*') + .trim_start_matches('*') + .to_owned(); + if name.is_empty() { + continue; + } + base_indent = leading_space_count(line); + current_param = Some(name); + current_lines.clear(); + let desc = desc_part.trim_start(); + if !desc.is_empty() { + current_lines.push(desc.to_owned()); + } + continue; + } + + if trimmed.starts_with(':') { + commit_parameter_doc(&mut current_param, &mut current_lines, docs); + continue; + } + + if current_param.is_some() { + if trimmed.is_empty() { + commit_parameter_doc(&mut current_param, &mut current_lines, docs); + continue; + } + let indent = leading_space_count(line); + if indent > base_indent { + current_lines.push(line.trim_start().to_owned()); + } else { + commit_parameter_doc(&mut current_param, &mut current_lines, docs); + } + } + } + + commit_parameter_doc(&mut current_param, &mut current_lines, docs); +} + +fn is_google_section(header: &str) -> bool { + matches!( + header, + "Args" | "Arguments" | "Parameters" | "Keyword Args" | "Keyword Arguments" + ) +} + +fn extract_google_param_name(header: &str) -> Option { + let token = header + .split_whitespace() + .next() + .unwrap_or("") + .split('(') + .next() + .unwrap_or("") + .trim(); + if token.is_empty() { + None + } else { + Some(token.to_owned()) + } +} + +/// Parse Google-style `Args:`/`Arguments:` sections of the form: +/// +/// ```text +/// Args: +/// foo (int): description +/// bar: another description +/// ``` +/// +/// See . +fn parse_google_params(lines: &[String], docs: &mut HashMap) { + let mut in_section = false; + let mut section_indent = 0usize; + let mut current_param = None; + let mut current_lines = Vec::new(); + let mut param_indent = 0usize; + + for line in lines { + let indent = leading_space_count(line); + let trimmed = line.trim(); + + if trimmed.is_empty() { + commit_parameter_doc(&mut current_param, &mut current_lines, docs); + continue; + } + + if trimmed.ends_with(':') { + let header = trimmed.trim_end_matches(':'); + if is_google_section(header) { + commit_parameter_doc(&mut current_param, &mut current_lines, docs); + in_section = true; + section_indent = indent; + continue; + } + if in_section && indent <= section_indent { + commit_parameter_doc(&mut current_param, &mut current_lines, docs); + in_section = false; + } + } else if in_section && indent <= section_indent { + commit_parameter_doc(&mut current_param, &mut current_lines, docs); + in_section = false; + } + + if !in_section { + continue; + } + + if indent <= section_indent { + commit_parameter_doc(&mut current_param, &mut current_lines, docs); + in_section = false; + continue; + } + + let content = line[section_indent.min(line.len())..].trim_start(); + if let Some((header, rest)) = content.split_once(':') + && let Some(name) = extract_google_param_name(header.trim()) + { + commit_parameter_doc(&mut current_param, &mut current_lines, docs); + current_param = Some(name); + current_lines.clear(); + param_indent = indent; + let desc = rest.trim_start(); + if !desc.is_empty() { + current_lines.push(desc.to_owned()); + } + continue; + } + + if current_param.is_some() { + if indent > param_indent { + current_lines.push(content.trim_start().to_owned()); + } else { + commit_parameter_doc(&mut current_param, &mut current_lines, docs); + } + } + } + + commit_parameter_doc(&mut current_param, &mut current_lines, docs); +} + +/// Extract a map of `parameter -> markdown` documentation snippets from the +/// supplied docstring, supporting both Sphinx (`:param foo:`) and +/// Google-style (`Args:`) formats. +pub fn parse_parameter_documentation(docstring: &str) -> HashMap { + let lines = dedented_lines_for_parsing(docstring); + let mut docs = HashMap::new(); + if lines.is_empty() { + return docs; + } + parse_sphinx_params(&lines, &mut docs); + parse_google_params(&lines, &mut docs); + docs +} + #[cfg(test)] mod tests { use crate::docstring::Docstring; + use crate::docstring::parse_parameter_documentation; #[test] fn test_clean_removes_double_multiline_double_quotes() { @@ -189,4 +426,28 @@ mod tests { "hello \nworld \ntest" ); } + #[test] + fn test_parse_sphinx_param_docs() { + let doc = r#" +:param foo: first line + second line +:param str bar: another +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "first line\nsecond line"); + assert_eq!(docs.get("bar").unwrap(), "another"); + } + + #[test] + fn test_parse_google_param_docs() { + let doc = r#" +Args: + foo (int): first line + second line + bar: final +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "first line\nsecond line"); + assert_eq!(docs.get("bar").unwrap(), "final"); + } } diff --git a/pyrefly/lib/lsp/wasm/hover.rs b/pyrefly/lib/lsp/wasm/hover.rs index 3fcd627a34..f34c720b7f 100644 --- a/pyrefly/lib/lsp/wasm/hover.rs +++ b/pyrefly/lib/lsp/wasm/hover.rs @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +use std::collections::HashMap; + use lsp_types::Hover; use lsp_types::HoverContents; use lsp_types::MarkupContent; @@ -12,6 +14,7 @@ use lsp_types::MarkupKind; use lsp_types::Url; use pyrefly_build::handle::Handle; use pyrefly_python::docstring::Docstring; +use pyrefly_python::docstring::parse_parameter_documentation; use pyrefly_python::ignore::Ignore; use pyrefly_python::ignore::Tool; use pyrefly_python::ignore::find_comment_start_in_line; @@ -24,13 +27,16 @@ use pyrefly_types::callable::Required; use pyrefly_types::types::Type; use pyrefly_util::lined_buffer::LineNumber; use ruff_python_ast::name::Name; +use ruff_text_size::TextRange; use ruff_text_size::TextSize; use crate::alt::answers_solver::AnswersSolver; use crate::error::error::Error; use crate::lsp::module_helpers::collect_symbol_def_paths; +use crate::state::lsp::DefinitionMetadata; use crate::state::lsp::FindDefinitionItemWithDocstring; use crate::state::lsp::FindPreference; +use crate::state::lsp::IdentifierContext; use crate::state::state::Transaction; use crate::state::state::TransactionHandle; @@ -104,6 +110,7 @@ pub struct HoverValue { pub name: Option, pub type_: Type, pub docstring: Option, + pub parameter_doc: Option<(String, String)>, pub display: Option, pub show_go_to_links: bool, } @@ -160,6 +167,18 @@ impl HoverValue { || "".to_owned(), |content| format!("\n---\n{}", content.trim()), ); + let parameter_doc_formatted = + self.parameter_doc + .as_ref() + .map_or("".to_owned(), |(name, doc)| { + let prefix = if self.docstring.is_some() { + "\n\n---\n" + } else { + "\n---\n" + }; + let cleaned = doc.trim().replace('\n', " \n"); + format!("{prefix}**Parameter `{}`**\n{}", name, cleaned) + }); let kind_formatted = self.kind.map_or("".to_owned(), |kind| { format!("{} ", kind.display_for_hover()) }); @@ -181,11 +200,12 @@ impl HoverValue { contents: HoverContents::Markup(MarkupContent { kind: MarkupKind::Markdown, value: format!( - "```python\n{}{}{}\n```{}{}", + "```python\n{}{}{}\n```{}{}{}", kind_formatted, name_formatted, type_display, docstring_formatted, + parameter_doc_formatted, symbol_def_formatted ), }), @@ -322,15 +342,112 @@ pub fn get_hover( None }; + let mut parameter_doc = keyword_argument_documentation(transaction, handle, position) + .and_then(|(name, doc)| (!doc.trim().is_empty()).then_some((name, doc))); + + if parameter_doc.is_none() + && let Some(FindDefinitionItemWithDocstring { + metadata: DefinitionMetadata::Variable(Some(SymbolKind::Parameter)), + definition_range, + module, + .. + }) = transaction + .find_definition(handle, position, FindPreference::default()) + .into_iter() + .next() + { + let name_str = module.code_at(definition_range); + let name = Name::new(name_str); + if let Some(doc) = + parameter_definition_documentation(transaction, handle, definition_range, &name) + { + parameter_doc = Some(doc); + } + } + Some( HoverValue { kind, name, type_, docstring, + parameter_doc, display: type_display, show_go_to_links, } .format(), ) } + +fn keyword_argument_documentation( + transaction: &Transaction<'_>, + handle: &Handle, + position: TextSize, +) -> Option<(String, String)> { + let identifier = transaction.identifier_at(handle, position)?; + if !matches!(identifier.context, IdentifierContext::KeywordArgument(_)) { + return None; + } + let (_, _, _, callee_range) = transaction.get_callables_from_call(handle, position)?; + let docs = parameter_documentation_for_callee(transaction, handle, callee_range)?; + let name = identifier.identifier.id.to_string(); + docs.get(name.as_str()).cloned().map(|doc| (name, doc)) +} + +fn parameter_definition_documentation( + transaction: &Transaction<'_>, + handle: &Handle, + definition_range: TextRange, + name: &Name, +) -> Option<(String, String)> { + let ast = transaction.get_ast(handle)?; + let module = transaction.get_module_info(handle)?; + + let func = ast + .body + .iter() + .filter_map(|stmt| match stmt { + ruff_python_ast::Stmt::FunctionDef(func) => Some(func), + _ => None, + }) + .find(|func| func.range.contains_inclusive(definition_range.start()))?; + + let doc_range = Docstring::range_from_stmts(func.body.as_slice())?; + let docs = parse_parameter_documentation(module.code_at(doc_range)); + let key = name.as_str(); + docs.get(key).cloned().map(|doc| (key.to_owned(), doc)) +} + +fn parameter_documentation_for_callee( + transaction: &Transaction<'_>, + handle: &Handle, + callee_range: TextRange, +) -> Option> { + let position = callee_range.start(); + let docstring = transaction + .find_definition( + handle, + position, + FindPreference { + prefer_pyi: false, + ..Default::default() + }, + ) + .into_iter() + .find_map(|item| { + item.docstring_range + .map(|range| (range, item.module.clone())) + }) + .or_else(|| { + transaction + .find_definition(handle, position, FindPreference::default()) + .into_iter() + .find_map(|item| { + item.docstring_range + .map(|range| (range, item.module.clone())) + }) + })?; + let (range, module) = docstring; + let docs = parse_parameter_documentation(module.code_at(range)); + if docs.is_empty() { None } else { Some(docs) } +} diff --git a/pyrefly/lib/lsp/wasm/signature_help.rs b/pyrefly/lib/lsp/wasm/signature_help.rs index 96cf219528..21d8b79e18 100644 --- a/pyrefly/lib/lsp/wasm/signature_help.rs +++ b/pyrefly/lib/lsp/wasm/signature_help.rs @@ -5,12 +5,15 @@ * LICENSE file in the root directory of this source tree. */ +use std::collections::HashMap; + use itertools::Itertools; use lsp_types::ParameterInformation; use lsp_types::ParameterLabel; use lsp_types::SignatureHelp; use lsp_types::SignatureInformation; use pyrefly_build::handle::Handle; +use pyrefly_python::docstring::parse_parameter_documentation; use pyrefly_util::prelude::VecExt; use pyrefly_util::visit::Visit; use ruff_python_ast::Expr; @@ -20,6 +23,7 @@ use ruff_text_size::Ranged; use ruff_text_size::TextRange; use ruff_text_size::TextSize; +use crate::state::lsp::FindPreference; use crate::state::lsp::visit_keyword_arguments_until_match; use crate::state::state::Transaction; use crate::types::callable::Param; @@ -115,7 +119,7 @@ impl Transaction<'_> { &self, handle: &Handle, position: TextSize, - ) -> Option<(Vec, usize, ActiveArgument)> { + ) -> Option<(Vec, usize, ActiveArgument, TextRange)> { let mod_module = self.get_ast(handle)?; let mut res = None; mod_module.visit(&mut |x| Self::visit_finding_signature_range(x, position, &mut res)); @@ -138,24 +142,32 @@ impl Transaction<'_> { callables, chosen_overload_index.unwrap_or_default(), active_argument, + callee_range, )) } else { answers .get_type_trace(callee_range) - .map(|t| (vec![t], 0, active_argument)) + .map(|t| (vec![t], 0, active_argument, callee_range)) } } - pub fn get_signature_help_at( + pub(crate) fn get_signature_help_at( &self, handle: &Handle, position: TextSize, ) -> Option { self.get_callables_from_call(handle, position).map( - |(callables, chosen_overload_index, active_argument)| { + |(callables, chosen_overload_index, active_argument, callee_range)| { + let parameter_docs = self.parameter_documentation_for_callee(handle, callee_range); let signatures = callables .into_iter() - .map(|t| Self::create_signature_information(t, &active_argument)) + .map(|t| { + Self::create_signature_information( + t, + &active_argument, + parameter_docs.as_ref(), + ) + }) .collect_vec(); let active_parameter = signatures .get(chosen_overload_index) @@ -169,9 +181,43 @@ impl Transaction<'_> { ) } - fn create_signature_information( + pub(crate) fn parameter_documentation_for_callee( + &self, + handle: &Handle, + callee_range: TextRange, + ) -> Option> { + let position = callee_range.start(); + let docstring = self + .find_definition( + handle, + position, + FindPreference { + prefer_pyi: false, + ..Default::default() + }, + ) + .into_iter() + .find_map(|item| { + item.docstring_range + .map(|range| (range, item.module.clone())) + }) + .or_else(|| { + self.find_definition(handle, position, FindPreference::default()) + .into_iter() + .find_map(|item| { + item.docstring_range + .map(|range| (range, item.module.clone())) + }) + })?; + let (range, module) = docstring; + let docs = parse_parameter_documentation(module.code_at(range)); + if docs.is_empty() { None } else { Some(docs) } + } + + pub(crate) fn create_signature_information( type_: Type, active_argument: &ActiveArgument, + parameter_docs: Option<&HashMap>, ) -> SignatureInformation { let type_ = type_.deterministic_printing(); let label = type_.as_hover_string(); @@ -185,7 +231,19 @@ impl Transaction<'_> { .into_iter() .map(|param| ParameterInformation { label: ParameterLabel::Simple(format!("{param}")), - documentation: None, + documentation: param + .name() + .and_then(|name| { + parameter_docs.and_then(|docs| docs.get(name.as_str())) + }) + .map(|text| { + lsp_types::Documentation::MarkupContent( + lsp_types::MarkupContent { + kind: lsp_types::MarkupKind::Markdown, + value: text.clone(), + }, + ) + }), }) .collect(), ), @@ -216,7 +274,7 @@ impl Transaction<'_> { } } - fn count_argument_separators_before( + pub(crate) fn count_argument_separators_before( &self, handle: &Handle, arguments_range: TextRange, @@ -236,7 +294,6 @@ impl Transaction<'_> { pub(crate) fn normalize_singleton_function_type_into_params(type_: Type) -> Option> { let callable = type_.to_callable()?; - // We will drop the self parameter for signature help if let Params::List(params_list) = callable.params { if let Some(Param::PosOnly(Some(name), _, _) | Param::Pos(name, _, _)) = params_list.items().first() diff --git a/pyrefly/lib/state/lsp.rs b/pyrefly/lib/state/lsp.rs index 664843ff71..f289c4dd0f 100644 --- a/pyrefly/lib/state/lsp.rs +++ b/pyrefly/lib/state/lsp.rs @@ -66,7 +66,6 @@ use crate::config::error_kind::ErrorKind; use crate::export::exports::Export; use crate::export::exports::ExportLocation; use crate::lsp::module_helpers::collect_symbol_def_paths; -use crate::lsp::wasm::inlay_hints::normalize_singleton_function_type_into_params; use crate::state::ide::IntermediateDefinition; use crate::state::ide::import_regular_import_edit; use crate::state::ide::insert_import_edit; @@ -186,7 +185,7 @@ impl DefinitionMetadata { } #[derive(Debug)] -enum CalleeKind { +pub(crate) enum CalleeKind { // Function name Function(Identifier), // Range of the base expr + method name @@ -218,7 +217,7 @@ where } #[derive(Debug)] -enum PatternMatchParameterKind { +pub(crate) enum PatternMatchParameterKind { // Name defined using `as` // ex: `x` in `case ... as x: ...`, or `x` in `case x: ...` AsName, @@ -234,7 +233,7 @@ enum PatternMatchParameterKind { } #[derive(Debug)] -enum IdentifierContext { +pub(crate) enum IdentifierContext { /// An identifier appeared in an expression. ex: `x` in `x + 1` Expr(ExprContext), /// An identifier appeared as the name of an attribute. ex: `y` in `x.y` @@ -298,9 +297,9 @@ enum IdentifierContext { } #[derive(Debug)] -struct IdentifierWithContext { - identifier: Identifier, - context: IdentifierContext, +pub(crate) struct IdentifierWithContext { + pub(crate) identifier: Identifier, + pub(crate) context: IdentifierContext, } #[derive(PartialEq, Eq)] @@ -515,7 +514,11 @@ impl<'a> Transaction<'a> { None } - fn identifier_at(&self, handle: &Handle, position: TextSize) -> Option { + pub(crate) fn identifier_at( + &self, + handle: &Handle, + position: TextSize, + ) -> Option { let mod_module = self.get_ast(handle)?; let covering_nodes = Ast::locate_node(&mod_module, position); Self::identifier_from_covering_nodes(&covering_nodes) @@ -2025,9 +2028,10 @@ impl<'a> Transaction<'a> { position: TextSize, completions: &mut Vec, ) { - if let Some((callables, overload_idx, _)) = self.get_callables_from_call(handle, position) + if let Some((callables, overload_idx, _, _)) = + self.get_callables_from_call(handle, position) && let Some(callable) = callables.get(overload_idx).cloned() - && let Some(params) = normalize_singleton_function_type_into_params(callable) + && let Some(params) = Self::normalize_singleton_function_type_into_params(callable) { for param in params { match param { @@ -2384,10 +2388,11 @@ impl<'a> Transaction<'a> { } } - if let Some((callables, chosen_overload_index, active_argument)) = + if let Some((callables, chosen_overload_index, active_argument, _)) = self.get_callables_from_call(handle, position) && let Some(callable) = callables.get(chosen_overload_index) - && let Some(params) = normalize_singleton_function_type_into_params(callable.clone()) + && let Some(params) = + Self::normalize_singleton_function_type_into_params(callable.clone()) && let Some(arg_index) = Self::active_parameter_index(¶ms, &active_argument) && let Some(param) = params.get(arg_index) { diff --git a/pyrefly/lib/test/lsp/hover.rs b/pyrefly/lib/test/lsp/hover.rs index 1306e951d5..b80a16f8b9 100644 --- a/pyrefly/lib/test/lsp/hover.rs +++ b/pyrefly/lib/test/lsp/hover.rs @@ -204,6 +204,32 @@ x: int = 5 # pyrefly: ignore[bad-return] assert!(report.contains("No errors suppressed")); } +#[test] +fn hover_shows_parameter_doc_for_keyword_argument() { + let code = r#" +def foo(x: int, y: int) -> None: + """ + Args: + x: documentation for x + y: documentation for y + """ + ... + +foo(x=1, y=2) +# ^ +foo(x=1, y=2) +# ^ +"#; + let report = get_batched_lsp_operations_report(&[("main", code)], get_test_report); + assert!( + report.contains("**Parameter `x`**"), + "Expected parameter documentation for x, got: {report}" + ); + assert!(report.contains("documentation for x")); + assert!(report.contains("**Parameter `y`**")); + assert!(report.contains("documentation for y")); +} + #[test] fn hover_over_overloaded_binary_operator_shows_dunder_name() { let code = r#" diff --git a/pyrefly/lib/test/lsp/signature_help.rs b/pyrefly/lib/test/lsp/signature_help.rs index fa7f2058c4..a98ace4eef 100644 --- a/pyrefly/lib/test/lsp/signature_help.rs +++ b/pyrefly/lib/test/lsp/signature_help.rs @@ -6,6 +6,7 @@ */ use itertools::Itertools; +use lsp_types::Documentation; use lsp_types::ParameterLabel; use lsp_types::SignatureHelp; use lsp_types::SignatureInformation; @@ -13,8 +14,11 @@ use pretty_assertions::assert_eq; use pyrefly_build::handle::Handle; use ruff_text_size::TextSize; +use crate::state::require::Require; use crate::state::state::State; +use crate::test::util::extract_cursors_for_test; use crate::test::util::get_batched_lsp_operations_report_allow_error; +use crate::test::util::mk_multi_file_state; fn get_test_report(state: &State, handle: &Handle, position: TextSize) -> String { if let Some(SignatureHelp { @@ -221,6 +225,47 @@ Signature Help Result: active=0 ); } +#[test] +fn parameter_documentation_test() { + let code = r#" +def foo(a: int, b: str) -> None: + """ + Args: + a: first line + second line + b: final + """ + pass + +foo(a=1, b="") +# ^ +"#; + let files = [("main", code)]; + let (handles, state) = mk_multi_file_state(&files, Require::indexing(), true); + let handle = handles.get("main").unwrap(); + let position = extract_cursors_for_test(code)[0]; + let signature = state + .transaction() + .get_signature_help_at(handle, position) + .expect("signature help available"); + let params = signature.signatures[0] + .parameters + .as_ref() + .expect("parameters available"); + let param_doc = params + .iter() + .find( + |param| matches!(¶m.label, ParameterLabel::Simple(label) if label.starts_with("a")), + ) + .and_then(|param| param.documentation.as_ref()) + .expect("parameter documentation"); + if let Documentation::MarkupContent(content) = param_doc { + assert_eq!(content.value, "first line\nsecond line"); + } else { + panic!("unexpected documentation variant"); + } +} + #[test] fn simple_incomplete_function_call_test() { let code = r#" From 3f0098418d426ee549ae01ebf993ac4438691ec7 Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 09:29:59 -0800 Subject: [PATCH 26/62] Implement write_type. Summary: This diff introduces the write_type function which is responsible for writing types fall outside of str, qname, targs, etc. This will defer the type back to fmt_helper_generic and use the existing match statement to figure out how to handle this type. Reviewed By: kinto0 Differential Revision: D87642081 fbshipit-source-id: a9846af0aad7b9af7dfd9ffe57fcf0dd811a674a --- crates/pyrefly_types/src/display.rs | 2 +- crates/pyrefly_types/src/type_output.rs | 72 ++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/crates/pyrefly_types/src/display.rs b/crates/pyrefly_types/src/display.rs index 30fd7b340f..c5a9153861 100644 --- a/crates/pyrefly_types/src/display.rs +++ b/crates/pyrefly_types/src/display.rs @@ -243,7 +243,7 @@ impl<'a> TypeDisplayContext<'a> { } } - fn fmt_helper_generic( + pub fn fmt_helper_generic( &self, t: &Type, is_toplevel: bool, diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index ba333d5904..59176b658a 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -69,7 +69,6 @@ impl<'a, 'b, 'f> TypeOutput for DisplayOutput<'a, 'b, 'f> { /// a location this will be included. For separators like '|', '[', etc. this will be None. pub struct OutputWithLocations<'a> { parts: Vec<(String, Option)>, - #[expect(dead_code)] context: &'a TypeDisplayContext<'a>, } @@ -133,8 +132,9 @@ impl TypeOutput for OutputWithLocations<'_> { Ok(()) } - fn write_type(&mut self, _ty: &Type) -> fmt::Result { - Ok(()) + fn write_type(&mut self, ty: &Type) -> fmt::Result { + // Format the type and extract location if it has a qname + self.context.fmt_helper_generic(ty, false, self) } } @@ -160,6 +160,7 @@ mod tests { use crate::literal::LitEnum; use crate::quantified::Quantified; use crate::quantified::QuantifiedKind; + use crate::tuple::Tuple; use crate::type_var::PreInferenceVariance; use crate::type_var::Restriction; use crate::types::TArgs; @@ -324,16 +325,71 @@ mod tests { output.write_targs(&targs).unwrap(); - // Should have "[", ", ", ", ", and "]" - // write_type is stubbed, so types themselves don't appear - assert_eq!(output.parts().len(), 4); + // Now that write_type is implemented, it actually writes the types + // Should have: "[", "None", ", ", "LiteralString", ", ", "Any", "]" + assert_eq!(output.parts().len(), 7); assert_eq!(output.parts()[0].0, "["); assert!(output.parts()[0].1.is_none()); - assert_eq!(output.parts()[1].0, ", "); + + assert_eq!(output.parts()[1].0, "None"); assert!(output.parts()[1].1.is_none()); + assert_eq!(output.parts()[2].0, ", "); assert!(output.parts()[2].1.is_none()); - assert_eq!(output.parts()[3].0, "]"); + + assert_eq!(output.parts()[3].0, "LiteralString"); assert!(output.parts()[3].1.is_none()); + + assert_eq!(output.parts()[4].0, ", "); + assert!(output.parts()[4].1.is_none()); + + assert_eq!(output.parts()[5].0, "Any"); + assert!(output.parts()[5].1.is_none()); + + assert_eq!(output.parts()[6].0, "]"); + assert!(output.parts()[6].1.is_none()); + } + + #[test] + fn test_output_with_locations_write_type_simple() { + let context = TypeDisplayContext::default(); + let mut output = OutputWithLocations::new(&context); + + // Test simple types that don't have locations + output.write_type(&Type::None).unwrap(); + assert_eq!(output.parts().len(), 1); + assert_eq!(output.parts()[0].0, "None"); + assert!(output.parts()[0].1.is_none()); + + output.write_type(&Type::LiteralString).unwrap(); + assert_eq!(output.parts().len(), 2); + assert_eq!(output.parts()[1].0, "LiteralString"); + assert!(output.parts()[1].1.is_none()); + } + + #[test] + fn test_output_with_locations_write_type_tuple() { + // Test tuple[int, str] + let int_class = fake_class("int", "builtins", 30); + let str_class = fake_class("str", "builtins", 40); + + let int_type = Type::ClassType(ClassType::new(int_class, TArgs::default())); + let str_type = Type::ClassType(ClassType::new(str_class, TArgs::default())); + let tuple_type = Type::Tuple(Tuple::Concrete(vec![int_type.clone(), str_type.clone()])); + + let context = TypeDisplayContext::new(&[&tuple_type, &int_type, &str_type]); + let mut output = OutputWithLocations::new(&context); + + output.write_type(&tuple_type).unwrap(); + assert!(!output.parts().is_empty()); + + // Find the int and str parts and verify they have locations + let int_part = output.parts().iter().find(|p| p.0 == "int"); + assert!(int_part.is_some()); + assert!(int_part.unwrap().1.is_some()); + + let str_part = output.parts().iter().find(|p| p.0 == "str"); + assert!(str_part.is_some()); + assert!(str_part.unwrap().1.is_some()); } } From b242eed5a21d3386f81c0b40337fab7bce999290 Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 09:29:59 -0800 Subject: [PATCH 27/62] Test that Union types do not split properly. Summary: Adds a test that asserts that currently for Union types the OutputWithLocations struct will parse the entire thing as one large part rather than being able to break it up into individual parts. A subsequent diff will add this functionality and update the test. Reviewed By: yangdanny97 Differential Revision: D87708494 fbshipit-source-id: fe59f545b263d529f331c0dee199765f1c50dfbb --- crates/pyrefly_types/src/type_output.rs | 33 +++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index 59176b658a..a8acefe6d2 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -392,4 +392,37 @@ mod tests { assert!(str_part.is_some()); assert!(str_part.unwrap().1.is_some()); } + + #[test] + fn test_output_with_locations_union_type_does_not_split_properly() { + // Create int | str | None type + let int_class = fake_class("int", "builtins", 10); + let str_class = fake_class("str", "builtins", 20); + + let int_type = Type::ClassType(ClassType::new(int_class, TArgs::default())); + let str_type = Type::ClassType(ClassType::new(str_class, TArgs::default())); + let union_type = Type::Union(vec![int_type, str_type, Type::None]); + + let ctx = TypeDisplayContext::new(&[&union_type]); + let mut output = OutputWithLocations::new(&ctx); + + // Format the type using fmt_helper_generic + ctx.fmt_helper_generic(&union_type, false, &mut output) + .unwrap(); + + // Check the concatenated result + let parts_str: String = output.parts().iter().map(|(s, _)| s.as_str()).collect(); + assert_eq!(parts_str, "int | str | None"); + + // Current behavior: The entire union is treated as one string + // This is technically incorrect - we want separate parts for each type + // Desired future behavior: [("int", Some(location)), (" | ", None), ("str", Some(location)), (" | ", None), ("None", None)] + let parts = output.parts(); + assert_eq!(parts.len(), 1, "Current behavior: union as single part"); + assert_eq!(parts[0].0, "int | str | None"); + assert!( + parts[0].1.is_none(), + "Current behavior: entire union has no location" + ); + } } From e5bf11e0f1ce56a1ba3ec751523f17762ad3596b Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Mon, 24 Nov 2025 10:13:51 -0800 Subject: [PATCH 28/62] fix unused variable detection for reassignments Summary: fix https://github.com/facebook/pyrefly/issues/1670 an alternative approach could be to use find-references to find unused variables. but if we ever want this diagnostic on the CLI (I think we do), we will likely want it less coupled to the language server. Reviewed By: stroxler Differential Revision: D87785750 fbshipit-source-id: 8f2abc30693714eb5df7523e36eb70c4f6eacd3e --- pyrefly/lib/binding/scope.rs | 10 +++- pyrefly/lib/test/lsp/diagnostic.rs | 84 ++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/pyrefly/lib/binding/scope.rs b/pyrefly/lib/binding/scope.rs index 4a5bb41583..608afabef7 100644 --- a/pyrefly/lib/binding/scope.rs +++ b/pyrefly/lib/binding/scope.rs @@ -1639,11 +1639,19 @@ impl Scopes { self.current().kind, ScopeKind::Module | ScopeKind::Function(_) | ScopeKind::Method(_) ) { + // Preserve the `used` flag if the variable was already marked as used + // This handles cases like `foo = foo + 1` in loops where the variable + // is read before being reassigned + let was_used = self + .current() + .variables + .get(&name.id) + .is_some_and(|usage| usage.used); self.current_mut().variables.insert( name.id.clone(), VariableUsage { range: name.range, - used: false, + used: was_used, }, ); } diff --git a/pyrefly/lib/test/lsp/diagnostic.rs b/pyrefly/lib/test/lsp/diagnostic.rs index 03161c65bd..1bd5248df8 100644 --- a/pyrefly/lib/test/lsp/diagnostic.rs +++ b/pyrefly/lib/test/lsp/diagnostic.rs @@ -31,6 +31,26 @@ fn get_unused_import_diagnostics(state: &State, handle: &Handle) -> String { } } +fn get_unused_variable_diagnostics(state: &State, handle: &Handle) -> String { + let transaction = state.transaction(); + if let Some(bindings) = transaction.get_bindings(handle) { + let unused_variables = bindings.unused_variables(); + if unused_variables.is_empty() { + return "No unused variables".to_owned(); + } + let mut report = String::new(); + for (i, unused) in unused_variables.iter().enumerate() { + if i > 0 { + report.push_str(", "); + } + report.push_str(&format!("Variable `{}` is unused", unused.name.as_str())); + } + report + } else { + "No bindings".to_owned() + } +} + #[test] fn test_dotted_import_used() { let code = r#" @@ -114,3 +134,67 @@ def process(items: List[str]): let report = get_unused_import_diagnostics(&state, handle); assert_eq!(report, "Import `Dict` is unused"); } + +#[test] +fn test_generator_with_send() { + let code = r#" +from typing import Generator + +def test() -> Generator[float, float, None]: + new = yield 0.0 + while True: + new = yield new - 1 +"#; + let (handles, state) = mk_multi_file_state(&[("main", code)], Require::indexing(), true); + let handle = handles.get("main").unwrap(); + let report = get_unused_variable_diagnostics(&state, handle); + assert_eq!(report, "No unused variables"); +} + +#[test] +fn test_loop_with_reassignment() { + let code = r#" +def test_loop() -> str: + foo = 0 + while True: + if foo > 100: + return f"bar {foo}" + foo = foo + 1 + continue +"#; + let (handles, state) = mk_multi_file_state(&[("main", code)], Require::indexing(), true); + let handle = handles.get("main").unwrap(); + let report = get_unused_variable_diagnostics(&state, handle); + assert_eq!(report, "No unused variables"); +} + +#[test] +fn test_loop_with_augmented_assignment() { + let code = r#" +def test_loop_aug() -> str: + foo = 0 + while True: + if foo > 100: + return f"bar {foo}" + foo += 1 + continue +"#; + let (handles, state) = mk_multi_file_state(&[("main", code)], Require::indexing(), true); + let handle = handles.get("main").unwrap(); + let report = get_unused_variable_diagnostics(&state, handle); + assert_eq!(report, "No unused variables"); +} + +#[test] +fn test_unused_variable_basic() { + let code = r#" +def main(): + unused_var = "this is unused" + used_var = "this is used" + print(used_var) +"#; + let (handles, state) = mk_multi_file_state(&[("main", code)], Require::indexing(), true); + let handle = handles.get("main").unwrap(); + let report = get_unused_variable_diagnostics(&state, handle); + assert_eq!(report, "Variable `unused_var` is unused"); +} From 4e02534ccdd8ae842f4bb3a1909e1db637758ef0 Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Mon, 24 Nov 2025 10:13:51 -0800 Subject: [PATCH 29/62] filter star imports from unused detection Summary: we should never warn on unused star imports Reviewed By: stroxler Differential Revision: D87788170 fbshipit-source-id: 61c3d914b674d309a44f2f03d3df9e6d628b78bd --- pyrefly/lib/binding/scope.rs | 8 +++++++- pyrefly/lib/binding/stmt.rs | 13 ++++++++----- pyrefly/lib/test/lsp/diagnostic.rs | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/pyrefly/lib/binding/scope.rs b/pyrefly/lib/binding/scope.rs index 608afabef7..89c71bf23b 100644 --- a/pyrefly/lib/binding/scope.rs +++ b/pyrefly/lib/binding/scope.rs @@ -804,6 +804,7 @@ struct ParameterUsage { struct ImportUsage { range: TextRange, used: bool, + is_star_import: bool, } #[derive(Clone, Debug)] @@ -1304,7 +1305,7 @@ impl Scopes { imports .into_iter() .filter_map(|(name, usage)| { - if usage.used { + if usage.used || usage.is_star_import { None } else { Some(UnusedImport { @@ -1611,12 +1612,17 @@ impl Scopes { } pub fn register_import(&mut self, name: &Identifier) { + self.register_import_with_star(name, false); + } + + pub fn register_import_with_star(&mut self, name: &Identifier, is_star_import: bool) { if matches!(self.current().kind, ScopeKind::Module) { self.current_mut().imports.insert( name.id.clone(), ImportUsage { range: name.range, used: false, + is_star_import, }, ); } diff --git a/pyrefly/lib/binding/stmt.rs b/pyrefly/lib/binding/stmt.rs index da383d76c5..9d31953b4f 100644 --- a/pyrefly/lib/binding/stmt.rs +++ b/pyrefly/lib/binding/stmt.rs @@ -1022,11 +1022,14 @@ impl<'a> BindingsBuilder<'a> { }; let key = self.insert_binding(key, val); // Register the imported name from wildcard imports - self.scopes.register_import(&Identifier { - node_index: AtomicNodeIndex::dummy(), - id: name.into_key().clone(), - range: x.range, - }); + self.scopes.register_import_with_star( + &Identifier { + node_index: AtomicNodeIndex::dummy(), + id: name.into_key().clone(), + range: x.range, + }, + true, + ); self.bind_name( name.key(), key, diff --git a/pyrefly/lib/test/lsp/diagnostic.rs b/pyrefly/lib/test/lsp/diagnostic.rs index 1bd5248df8..390fd93c71 100644 --- a/pyrefly/lib/test/lsp/diagnostic.rs +++ b/pyrefly/lib/test/lsp/diagnostic.rs @@ -135,6 +135,20 @@ def process(items: List[str]): assert_eq!(report, "Import `Dict` is unused"); } +#[test] +fn test_star_import_not_reported_as_unused() { + let code = r#" +from typing import * + +def foo() -> str: + return "hello" +"#; + let (handles, state) = mk_multi_file_state(&[("main", code)], Require::indexing(), true); + let handle = handles.get("main").unwrap(); + let report = get_unused_import_diagnostics(&state, handle); + assert_eq!(report, "No unused imports"); +} + #[test] fn test_generator_with_send() { let code = r#" From 7c365fae5926c10435991f34f0965979ae1e8fb8 Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Mon, 24 Nov 2025 10:13:51 -0800 Subject: [PATCH 30/62] highlight false negative Summary: see title Reviewed By: stroxler Differential Revision: D87790040 fbshipit-source-id: facdcb1cfb4fd6e733234090b0788c6346928212 --- pyrefly/lib/test/lsp/diagnostic.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pyrefly/lib/test/lsp/diagnostic.rs b/pyrefly/lib/test/lsp/diagnostic.rs index 390fd93c71..c1524d16cf 100644 --- a/pyrefly/lib/test/lsp/diagnostic.rs +++ b/pyrefly/lib/test/lsp/diagnostic.rs @@ -165,6 +165,21 @@ def test() -> Generator[float, float, None]: assert_eq!(report, "No unused variables"); } +// TODO: x = 7 should be highlighted as unused +#[test] +fn test_reassignment_false_negative() { + let code = r#" +def f(): + x = 5 + print(x) + x = 7 +"#; + let (handles, state) = mk_multi_file_state(&[("main", code)], Require::indexing(), true); + let handle = handles.get("main").unwrap(); + let report = get_unused_variable_diagnostics(&state, handle); + assert_eq!(report, "No unused variables"); +} + #[test] fn test_loop_with_reassignment() { let code = r#" From aeb0c55298511fad68b6f6247b8be08e76f6caa5 Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Mon, 24 Nov 2025 10:13:51 -0800 Subject: [PATCH 31/62] bump version Summary: see title. trying to get fix in D87785750 out Reviewed By: stroxler Differential Revision: D87790330 fbshipit-source-id: 55d391dc2f41d1e9a14c65b82953a5b4f90ef490 --- Cargo.lock | 18 +++++++++--------- crates/pyrefly_build/Cargo.toml | 2 +- crates/pyrefly_bundled/Cargo.toml | 2 +- crates/pyrefly_config/Cargo.toml | 2 +- crates/pyrefly_derive/Cargo.toml | 2 +- crates/pyrefly_python/Cargo.toml | 2 +- crates/pyrefly_types/Cargo.toml | 2 +- crates/pyrefly_util/Cargo.toml | 2 +- crates/tsp_types/Cargo.toml | 2 +- pyrefly/Cargo.toml | 2 +- version.bzl | 2 +- 11 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bccfe7fd8e..6f82e38e38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2022,7 +2022,7 @@ dependencies = [ [[package]] name = "pyrefly" -version = "0.43.0" +version = "0.43.1" dependencies = [ "anstream", "anyhow", @@ -2075,7 +2075,7 @@ dependencies = [ [[package]] name = "pyrefly_build" -version = "0.43.0" +version = "0.43.1" dependencies = [ "anyhow", "dupe", @@ -2093,7 +2093,7 @@ dependencies = [ [[package]] name = "pyrefly_bundled" -version = "0.43.0" +version = "0.43.1" dependencies = [ "anyhow", "sha2", @@ -2104,7 +2104,7 @@ dependencies = [ [[package]] name = "pyrefly_config" -version = "0.43.0" +version = "0.43.1" dependencies = [ "anyhow", "clap", @@ -2139,7 +2139,7 @@ dependencies = [ [[package]] name = "pyrefly_derive" -version = "0.43.0" +version = "0.43.1" dependencies = [ "proc-macro2", "quote", @@ -2148,7 +2148,7 @@ dependencies = [ [[package]] name = "pyrefly_python" -version = "0.43.0" +version = "0.43.1" dependencies = [ "anyhow", "clap", @@ -2175,7 +2175,7 @@ dependencies = [ [[package]] name = "pyrefly_types" -version = "0.43.0" +version = "0.43.1" dependencies = [ "compact_str 0.8.0", "dupe", @@ -2195,7 +2195,7 @@ dependencies = [ [[package]] name = "pyrefly_util" -version = "0.43.0" +version = "0.43.1" dependencies = [ "anstream", "anyhow", @@ -3354,7 +3354,7 @@ dependencies = [ [[package]] name = "tsp_types" -version = "0.43.0" +version = "0.43.1" dependencies = [ "lsp-server", "lsp-types", diff --git a/crates/pyrefly_build/Cargo.toml b/crates/pyrefly_build/Cargo.toml index ebe9561212..175117f632 100644 --- a/crates/pyrefly_build/Cargo.toml +++ b/crates/pyrefly_build/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_build" -version = "0.43.0" +version = "0.43.1" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_bundled/Cargo.toml b/crates/pyrefly_bundled/Cargo.toml index 3750f472cb..2a67c4c5a2 100644 --- a/crates/pyrefly_bundled/Cargo.toml +++ b/crates/pyrefly_bundled/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_bundled" -version = "0.43.0" +version = "0.43.1" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_config/Cargo.toml b/crates/pyrefly_config/Cargo.toml index 46194d08d8..4c31d51bab 100644 --- a/crates/pyrefly_config/Cargo.toml +++ b/crates/pyrefly_config/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_config" -version = "0.43.0" +version = "0.43.1" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_derive/Cargo.toml b/crates/pyrefly_derive/Cargo.toml index ef7b5abc6b..749f5e7919 100644 --- a/crates/pyrefly_derive/Cargo.toml +++ b/crates/pyrefly_derive/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_derive" -version = "0.43.0" +version = "0.43.1" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_python/Cargo.toml b/crates/pyrefly_python/Cargo.toml index ffde10c27a..7b1ba86060 100644 --- a/crates/pyrefly_python/Cargo.toml +++ b/crates/pyrefly_python/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_python" -version = "0.43.0" +version = "0.43.1" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_types/Cargo.toml b/crates/pyrefly_types/Cargo.toml index 55fcacc207..b88be3b894 100644 --- a/crates/pyrefly_types/Cargo.toml +++ b/crates/pyrefly_types/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_types" -version = "0.43.0" +version = "0.43.1" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_util/Cargo.toml b/crates/pyrefly_util/Cargo.toml index d616690b3e..7325aa20ae 100644 --- a/crates/pyrefly_util/Cargo.toml +++ b/crates/pyrefly_util/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_util" -version = "0.43.0" +version = "0.43.1" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/tsp_types/Cargo.toml b/crates/tsp_types/Cargo.toml index 8062fd84de..90989f622c 100644 --- a/crates/tsp_types/Cargo.toml +++ b/crates/tsp_types/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "tsp_types" -version = "0.43.0" +version = "0.43.1" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/pyrefly/Cargo.toml b/pyrefly/Cargo.toml index d8cc9bd0a6..9e9892655f 100644 --- a/pyrefly/Cargo.toml +++ b/pyrefly/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly" -version = "0.43.0" +version = "0.43.1" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/version.bzl b/version.bzl index 746d759eda..8fcb248ecd 100644 --- a/version.bzl +++ b/version.bzl @@ -13,4 +13,4 @@ # * After updating the version, run `arc autocargo -p pyrefly` to regenerate `Cargo.toml` # and put the resulting diff up for review. Once the diff lands, the new version should be # available on PyPI within a few hours. -VERSION = "0.43.0" +VERSION = "0.43.1" From 32cfc95c6a934b72ea95aed27573217ef47d4fd2 Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 10:46:58 -0800 Subject: [PATCH 32/62] Add test to show the Intersection types do not split properly. Summary: Adds two tests demonstrating that OutputWithLocations currently treats union and intersection types as single monolithic strings without preserving location information for individual type components. These tests document the expected behavior (splitting types into separate parts with locations) and serve as regression tests for the fixes implemented in subsequent commits. Reviewed By: kinto0 Differential Revision: D87642078 fbshipit-source-id: 685989709affb3a4714ee139fb9cf52ddb1617a0 --- crates/pyrefly_types/src/type_output.rs | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index a8acefe6d2..7dd214debb 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -425,4 +425,45 @@ mod tests { "Current behavior: entire union has no location" ); } + + #[test] + fn test_output_with_locations_intersection_type_does_not_split_properly() { + // Create int & str type (doesn't make sense semantically, but tests the formatting) + let int_type = Type::ClassType(ClassType::new( + fake_class("int", "builtins", 10), + TArgs::default(), + )); + let str_type = Type::ClassType(ClassType::new( + fake_class("str", "builtins", 20), + TArgs::default(), + )); + let intersect_type = + Type::Intersect(Box::new((vec![int_type, str_type], Type::any_implicit()))); + + let ctx = TypeDisplayContext::new(&[&intersect_type]); + let mut output = OutputWithLocations::new(&ctx); + + // Format the type using fmt_helper_generic + ctx.fmt_helper_generic(&intersect_type, false, &mut output) + .unwrap(); + + // Check the concatenated result + let parts_str: String = output.parts().iter().map(|(s, _)| s.as_str()).collect(); + assert_eq!(parts_str, "int & str"); + + // Current behavior: The entire intersection is treated as one string + // This is technically incorrect - we want separate parts for each type + // Desired future behavior: [("int", Some(location)), (" & ", None), ("str", Some(location))] + let parts = output.parts(); + assert_eq!( + parts.len(), + 1, + "Current behavior: intersection as single part" + ); + assert_eq!(parts[0].0, "int & str"); + assert!( + parts[0].1.is_none(), + "Current behavior: entire intersection has no location" + ); + } } From 29bfa4797860f11862d269bc250bc993c080d111 Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 10:46:58 -0800 Subject: [PATCH 33/62] Create fmt_type_sequence helper function to handle types with separators. Summary: Extracts common type sequence formatting logic into a new fmt_type_sequence helper that handles formatting multiple types with a separator. This helper properly wraps callable and intersection types in parentheses when needed (e.g., "(Callable) | int") and will be reused for both union and intersection type formatting, enabling proper location tracking for each type component. Reviewed By: kinto0 Differential Revision: D87642076 fbshipit-source-id: cc9708a2d5343812db34d2fa4620b9ca1dd45886 --- crates/pyrefly_types/src/display.rs | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/pyrefly_types/src/display.rs b/crates/pyrefly_types/src/display.rs index c5a9153861..2966778186 100644 --- a/crates/pyrefly_types/src/display.rs +++ b/crates/pyrefly_types/src/display.rs @@ -243,6 +243,37 @@ impl<'a> TypeDisplayContext<'a> { } } + /// Helper function to format a sequence of types with a separator. + /// Used for unions, intersections, and other type sequences. + #[expect(dead_code)] + fn fmt_type_sequence<'b>( + &self, + types: impl IntoIterator, + separator: &str, + wrap_callables_and_intersect: bool, + output: &mut impl TypeOutput, + ) -> fmt::Result { + for (i, t) in types.into_iter().enumerate() { + if i > 0 { + output.write_str(separator)?; + } + + let needs_parens = wrap_callables_and_intersect + && matches!( + t, + Type::Callable(_) | Type::Function(_) | Type::Intersect(_) + ); + if needs_parens { + output.write_str("(")?; + } + self.fmt_helper_generic(t, false, output)?; + if needs_parens { + output.write_str(")")?; + } + } + Ok(()) + } + pub fn fmt_helper_generic( &self, t: &Type, From 7aa0e900391dff4d86f9d7b931be2fa46c61829f Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 10:46:58 -0800 Subject: [PATCH 34/62] Update Union Logic to properly split into parts. Summary: Refactors union type formatting to output individual type components and separators as distinct parts, enabling location tracking for each type in the union. Literal types within unions are still combined into a single "Literal[a, b, c]" for better readability. The refactored code uses the new fmt_type_sequence helper for non-literal union members and manually formats the combined literal, calling write_str and write_type methods on the TypeOutput trait instead of building intermediate strings. This fixes the test_output_with_locations_union_type_splits_properly test. Reviewed By: kinto0 Differential Revision: D87642077 fbshipit-source-id: c7b6f49973be8c25adcddfd663c6a111c9b35cce --- crates/pyrefly_types/src/display.rs | 85 +++++++++++++++++++------ crates/pyrefly_types/src/type_output.rs | 32 ++++++---- 2 files changed, 84 insertions(+), 33 deletions(-) diff --git a/crates/pyrefly_types/src/display.rs b/crates/pyrefly_types/src/display.rs index 2966778186..84631ed994 100644 --- a/crates/pyrefly_types/src/display.rs +++ b/crates/pyrefly_types/src/display.rs @@ -245,7 +245,6 @@ impl<'a> TypeDisplayContext<'a> { /// Helper function to format a sequence of types with a separator. /// Used for unions, intersections, and other type sequences. - #[expect(dead_code)] fn fmt_type_sequence<'b>( &self, types: impl IntoIterator, @@ -447,19 +446,30 @@ impl<'a> TypeDisplayContext<'a> { self.maybe_fmt_with_module("typing", "Never", output) } Type::Union(types) => { - // All Literals will be collected into a single Literal at the index of the first Literal. let mut literal_idx = None; let mut literals = Vec::new(); - let mut display_types = Vec::new(); - for (i, t) in types.iter().enumerate() { + let mut union_members: Vec<&Type> = Vec::new(); + // Track seen types to deduplicate (mainly to prettify types for functions with different names but the same signature) + let mut seen_types = SmallSet::new(); + + for t in types.iter() { match t { Type::Literal(lit) => { if literal_idx.is_none() { - literal_idx = Some(i); + // First literal encountered: save this position in union_members. + // All Literal types in the union will be combined into a single + // "Literal[a, b, c]" output at this position for readability. + // Example: int | Literal[1] | str | Literal[2] → int | Literal[1, 2] | str + literal_idx = Some(union_members.len()); + // Insert a placeholder since we don't know all literals yet. + // When outputting (line 505), we check `if i == idx` to detect this + // placeholder position and output the combined literal instead. + union_members.push(&Type::None); } - literals.push(format!("{}", Fmt(|f| self.fmt_lit(lit, f)))) + literals.push(lit) } Type::Callable(_) | Type::Function(_) | Type::Intersect(_) => { + // These types need parentheses in union context let mut temp = String::new(); { use std::fmt::Write; @@ -469,9 +479,13 @@ impl<'a> TypeDisplayContext<'a> { }); write!(&mut temp, "({})", temp_formatter).ok(); } - display_types.push(temp) + // Only add if we haven't seen this type string before + if seen_types.insert(temp) { + union_members.push(t); + } } _ => { + // Format the type to a string for deduplication let mut temp = String::new(); { use std::fmt::Write; @@ -481,25 +495,54 @@ impl<'a> TypeDisplayContext<'a> { }); write!(&mut temp, "{}", temp_formatter).ok(); } - display_types.push(temp) + // Only add if we haven't seen this type string before + if seen_types.insert(temp) { + union_members.push(t); + } } } } - if let Some(i) = literal_idx { - if self.always_display_module_name { - display_types - .insert(i, format!("typing.Literal[{}]", commas_iter(|| &literals))); - } else { - display_types.insert(i, format!("Literal[{}]", commas_iter(|| &literals))); + + // If we found literals, create a combined Literal type and replace the placeholder + if let Some(idx) = literal_idx { + // We need to format the combined Literal manually since it's not a real Type + // but a special formatting construct + for (i, t) in union_members.iter().enumerate() { + if i > 0 { + output.write_str(" | ")?; + } + + if i == idx { + // This is where the combined Literal goes + self.maybe_fmt_with_module("typing", "Literal", output)?; + output.write_str("[")?; + for (j, lit) in literals.iter().enumerate() { + if j > 0 { + output.write_str(", ")?; + } + output.write_lit(lit)?; + } + output.write_str("]")?; + } else { + // Regular union member - use helper for just this one + let needs_parens = matches!( + t, + Type::Callable(_) | Type::Function(_) | Type::Intersect(_) + ); + if needs_parens { + output.write_str("(")?; + } + self.fmt_helper_generic(t, false, output)?; + if needs_parens { + output.write_str(")")?; + } + } } + Ok(()) + } else { + // No literals, just use the helper directly + self.fmt_type_sequence(union_members, " | ", true, output) } - // This is mainly to prettify types for functions with different names but the same signature - let display_types_deduped = display_types - .into_iter() - .collect::>() - .into_iter() - .collect::>(); - output.write_str(&display_types_deduped.join(" | ")) } Type::Intersect(x) => { let display_types: Vec = diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index 7dd214debb..890ea18687 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -394,7 +394,7 @@ mod tests { } #[test] - fn test_output_with_locations_union_type_does_not_split_properly() { + fn test_output_with_locations_union_type_splits_properly() { // Create int | str | None type let int_class = fake_class("int", "builtins", 10); let str_class = fake_class("str", "builtins", 20); @@ -406,24 +406,32 @@ mod tests { let ctx = TypeDisplayContext::new(&[&union_type]); let mut output = OutputWithLocations::new(&ctx); - // Format the type using fmt_helper_generic ctx.fmt_helper_generic(&union_type, false, &mut output) .unwrap(); - // Check the concatenated result let parts_str: String = output.parts().iter().map(|(s, _)| s.as_str()).collect(); assert_eq!(parts_str, "int | str | None"); - // Current behavior: The entire union is treated as one string - // This is technically incorrect - we want separate parts for each type - // Desired future behavior: [("int", Some(location)), (" | ", None), ("str", Some(location)), (" | ", None), ("None", None)] + // New behavior: Union types are split into separate parts + // Expected: [("int", Some(location)), (" | ", None), ("str", Some(location)), (" | ", None), ("None", None)] let parts = output.parts(); - assert_eq!(parts.len(), 1, "Current behavior: union as single part"); - assert_eq!(parts[0].0, "int | str | None"); - assert!( - parts[0].1.is_none(), - "Current behavior: entire union has no location" - ); + assert_eq!(parts.len(), 5, "Union should be split into 5 parts"); + + // Verify each part + assert_eq!(parts[0].0, "int"); + assert!(parts[0].1.is_some(), "int should have location"); + + assert_eq!(parts[1].0, " | "); + assert!(parts[1].1.is_none(), "separator should not have location"); + + assert_eq!(parts[2].0, "str"); + assert!(parts[2].1.is_some(), "str should have location"); + + assert_eq!(parts[3].0, " | "); + assert!(parts[3].1.is_none(), "separator should not have location"); + + assert_eq!(parts[4].0, "None"); + assert!(parts[4].1.is_none(), "None should not have location"); } #[test] From 11c7137a8a273614c74ea2e37aed34319c86f45b Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 10:46:58 -0800 Subject: [PATCH 35/62] Update intersection types to properly split into parts. Summary: Simplifies intersection type formatting by replacing string-building logic with a direct call to fmt_type_sequence. This change enables proper location tracking for each type component in an intersection, matching the union type behavior implemented in the previous commit. The refactoring eliminates ~30 lines of redundant code and fixes the test_output_with_locations_intersection_type_splits_properly test. Reviewed By: kinto0 Differential Revision: D87642080 fbshipit-source-id: 6b17b93f8feb61aba7b723c7fd0a5c419db7dbc7 --- crates/pyrefly_types/src/display.rs | 30 +------------------------ crates/pyrefly_types/src/type_output.rs | 28 +++++++++++------------ 2 files changed, 15 insertions(+), 43 deletions(-) diff --git a/crates/pyrefly_types/src/display.rs b/crates/pyrefly_types/src/display.rs index 84631ed994..ff86483325 100644 --- a/crates/pyrefly_types/src/display.rs +++ b/crates/pyrefly_types/src/display.rs @@ -544,35 +544,7 @@ impl<'a> TypeDisplayContext<'a> { self.fmt_type_sequence(union_members, " | ", true, output) } } - Type::Intersect(x) => { - let display_types: Vec = - x.0.iter() - .map(|t| { - let mut temp = String::new(); - { - use std::fmt::Write; - match t { - Type::Callable(_) | Type::Function(_) => { - let temp_formatter = Fmt(|f| { - let mut temp_output = DisplayOutput::new(self, f); - self.fmt_helper_generic(t, false, &mut temp_output) - }); - write!(&mut temp, "({})", temp_formatter).ok(); - } - _ => { - let temp_formatter = Fmt(|f| { - let mut temp_output = DisplayOutput::new(self, f); - self.fmt_helper_generic(t, false, &mut temp_output) - }); - write!(&mut temp, "{}", temp_formatter).ok(); - } - } - } - temp - }) - .collect(); - output.write_str(&display_types.join(" & ")) - } + Type::Intersect(x) => self.fmt_type_sequence(x.0.iter(), " & ", true, output), Type::Tuple(t) => { t.fmt_with_type(output, &|ty, o| self.fmt_helper_generic(ty, false, o)) } diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index 890ea18687..26d3991c94 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -435,7 +435,7 @@ mod tests { } #[test] - fn test_output_with_locations_intersection_type_does_not_split_properly() { + fn test_output_with_locations_intersection_type_splits_properly() { // Create int & str type (doesn't make sense semantically, but tests the formatting) let int_type = Type::ClassType(ClassType::new( fake_class("int", "builtins", 10), @@ -459,19 +459,19 @@ mod tests { let parts_str: String = output.parts().iter().map(|(s, _)| s.as_str()).collect(); assert_eq!(parts_str, "int & str"); - // Current behavior: The entire intersection is treated as one string - // This is technically incorrect - we want separate parts for each type - // Desired future behavior: [("int", Some(location)), (" & ", None), ("str", Some(location))] + // New behavior: Intersection types are split into separate parts + // Expected: [("int", Some(location)), (" & ", None), ("str", Some(location))] let parts = output.parts(); - assert_eq!( - parts.len(), - 1, - "Current behavior: intersection as single part" - ); - assert_eq!(parts[0].0, "int & str"); - assert!( - parts[0].1.is_none(), - "Current behavior: entire intersection has no location" - ); + assert_eq!(parts.len(), 3, "Intersection should be split into 3 parts"); + + // Verify each part + assert_eq!(parts[0].0, "int"); + assert!(parts[0].1.is_some(), "int should have location"); + + assert_eq!(parts[1].0, " & "); + assert!(parts[1].1.is_none(), "separator should not have location"); + + assert_eq!(parts[2].0, "str"); + assert!(parts[2].1.is_some(), "str should have location"); } } From 1ff453d564c49f39907f50d6b134f5a25570484c Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 11:31:09 -0800 Subject: [PATCH 36/62] Add test showing that tuple type does not have a location. Summary: Currently if you have a type like `tuple[int]`, the int portion of the type will have a location but the `tuple` portion does not. This diff adds a test to cover this case and will be updated once that functionality is implemented. Also updates the formatting for tuples so that `tuple` and `[` are separate parts rather than just a single `tuple[` part. Reviewed By: kinto0 Differential Revision: D87642082 fbshipit-source-id: dcd0e44785a87ba608ef2431fb0c8a29cae0a816 --- crates/pyrefly_types/src/tuple.rs | 3 +- crates/pyrefly_types/src/type_output.rs | 42 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/pyrefly_types/src/tuple.rs b/crates/pyrefly_types/src/tuple.rs index 9b5fb3b11e..84161a8892 100644 --- a/crates/pyrefly_types/src/tuple.rs +++ b/crates/pyrefly_types/src/tuple.rs @@ -64,7 +64,8 @@ impl Tuple { output: &mut O, write_type: &impl Fn(&Type, &mut O) -> fmt::Result, ) -> fmt::Result { - output.write_str("tuple[")?; + output.write_str("tuple")?; + output.write_str("[")?; match self { Self::Concrete(elts) => { if elts.is_empty() { diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index 26d3991c94..e3f466a56b 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -474,4 +474,46 @@ mod tests { assert_eq!(parts[2].0, "str"); assert!(parts[2].1.is_some(), "str should have location"); } + + #[test] + fn test_output_with_locations_tuple_base_not_clickable() { + // TODO(jvansch): When implementing clickable support for the base type in generics like tuple[int], + // update this test to verify that "tuple" has a location and is clickable. + // Expected future behavior: [("tuple", Some(location)), ("[", None), ("int", Some(location)), ("]", None)] + + // Create tuple[int] type + let int_class = fake_class("int", "builtins", 10); + let int_type = Type::ClassType(ClassType::new(int_class, TArgs::default())); + let tuple_type = Type::Tuple(Tuple::Concrete(vec![int_type])); + + let ctx = TypeDisplayContext::new(&[&tuple_type]); + let mut output = OutputWithLocations::new(&ctx); + + ctx.fmt_helper_generic(&tuple_type, false, &mut output) + .unwrap(); + + let parts_str: String = output.parts().iter().map(|(s, _)| s.as_str()).collect(); + assert_eq!(parts_str, "tuple[int]"); + + // Current behavior: The "tuple" part is NOT clickable + // Expected parts: [("tuple[", None), ("int", Some(location)), ("]", None)] + let parts = output.parts(); + assert_eq!(parts.len(), 4, "Should have 3 parts"); + + // Verify each part + assert_eq!(parts[0].0, "tuple"); + assert!( + parts[0].1.is_none(), + "tuple[ should not have location (not clickable)" + ); + + assert_eq!(parts[1].0, "["); + assert!(parts[1].1.is_none(), "[ should not have location"); + + assert_eq!(parts[2].0, "int"); + assert!(parts[2].1.is_some(), "int should have location (clickable)"); + + assert_eq!(parts[3].0, "]"); + assert!(parts[3].1.is_none(), "] should not have location"); + } } From deb5c233502504a97f671b4f32d3a94c9e74609b Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 11:31:09 -0800 Subject: [PATCH 37/62] Add test showing that types from typing.py do not have locations. Summary: Currently types from typing.py will not have clickable locations. This diff adds a test which specifically tests that a type with `Literal` will not be clickable. Reviewed By: kinto0 Differential Revision: D87642084 fbshipit-source-id: e2e7c681f58860c61b2ec686ce2165e1fef7f2f9 --- crates/pyrefly_types/src/type_output.rs | 45 +++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index e3f466a56b..2bda6edec2 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -157,6 +157,7 @@ mod tests { use crate::class::Class; use crate::class::ClassDefIndex; use crate::class::ClassType; + use crate::lit_int::LitInt; use crate::literal::LitEnum; use crate::quantified::Quantified; use crate::quantified::QuantifiedKind; @@ -496,9 +497,9 @@ mod tests { assert_eq!(parts_str, "tuple[int]"); // Current behavior: The "tuple" part is NOT clickable - // Expected parts: [("tuple[", None), ("int", Some(location)), ("]", None)] + // Expected parts: [("tuple", None), ("[", None), ("int", Some(location)), ("]", None)] let parts = output.parts(); - assert_eq!(parts.len(), 4, "Should have 3 parts"); + assert_eq!(parts.len(), 4, "Should have 4 parts"); // Verify each part assert_eq!(parts[0].0, "tuple"); @@ -516,4 +517,44 @@ mod tests { assert_eq!(parts[3].0, "]"); assert!(parts[3].1.is_none(), "] should not have location"); } + + #[test] + fn test_output_with_locations_literal_base_not_clickable() { + // TODO(jvansch): When implementing clickable support for the base type in special forms like Literal[1], + // update this test to verify that "Literal" has a location and is clickable. + // Expected future behavior: [("Literal", Some(location)), ("[", None), ("1", None), ("]", None)] + + // Create Literal[1] type + let literal_type = Type::Literal(Lit::Int(LitInt::new(1))); + + let ctx = TypeDisplayContext::new(&[&literal_type]); + let mut output = OutputWithLocations::new(&ctx); + + ctx.fmt_helper_generic(&literal_type, false, &mut output) + .unwrap(); + + let parts_str: String = output.parts().iter().map(|(s, _)| s.as_str()).collect(); + assert_eq!(parts_str, "Literal[1]"); + + // Current behavior: The "Literal" part is NOT clickable + // Expected parts: [("Literal", None), ("[", None), ("1", None), ("]", None)] + let parts = output.parts(); + assert_eq!(parts.len(), 4, "Should have 4 parts"); + + // Verify each part + assert_eq!(parts[0].0, "Literal"); + assert!( + parts[0].1.is_none(), + "Literal should not have location (not clickable)" + ); + + assert_eq!(parts[1].0, "["); + assert!(parts[1].1.is_none(), "[ should not have location"); + + assert_eq!(parts[2].0, "1"); + assert!(parts[2].1.is_none(), "1 should not have location"); + + assert_eq!(parts[3].0, "]"); + assert!(parts[3].1.is_none(), "] should not have location"); + } } From 394a6faeb5bd8464119c2c95613634781e90b656 Mon Sep 17 00:00:00 2001 From: Jia Chen Date: Mon, 24 Nov 2025 11:42:03 -0800 Subject: [PATCH 38/62] Fix crashes when `yield` or `yield from` appears in type annotations Summary: dogscience_hotfix Fixes https://github.com/facebook/pyrefly/issues/1669 Reviewed By: rchen152 Differential Revision: D87801231 fbshipit-source-id: cccdac346683fc91d18954d32755ac88e7b72dbb --- pyrefly/lib/binding/expr.rs | 7 +++++++ pyrefly/lib/test/simple.rs | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/pyrefly/lib/binding/expr.rs b/pyrefly/lib/binding/expr.rs index 6ec9c5f5d7..de27cede10 100644 --- a/pyrefly/lib/binding/expr.rs +++ b/pyrefly/lib/binding/expr.rs @@ -831,6 +831,13 @@ impl<'a> BindingsBuilder<'a> { Expr::Call(_) => self.ensure_expr(x, static_type_usage), // Bind walrus so we don't crash when looking up the assigned name later. Expr::Named(_) => self.ensure_expr(x, static_type_usage), + // Bind yield and yield from so we don't crash when checking return type later. + Expr::Yield(_) => { + self.ensure_expr(x, static_type_usage); + } + Expr::YieldFrom(_) => { + self.ensure_expr(x, static_type_usage); + } Expr::Attribute(ExprAttribute { value, attr, .. }) if let Expr::Name(value) = &**value // We assume "args" and "kwargs" are ParamSpec attributes rather than imported TypeVars. diff --git a/pyrefly/lib/test/simple.rs b/pyrefly/lib/test/simple.rs index 948172e2dc..736ba8cbf4 100644 --- a/pyrefly/lib/test/simple.rs +++ b/pyrefly/lib/test/simple.rs @@ -1921,3 +1921,21 @@ x: (y := 1) # E: Expected a type form z: int = y "#, ); + +testcase!( + test_yield_in_annotation, + r#" +from typing import reveal_type +def test(): + x: (yield 1) # E: + "#, +); + +testcase!( + test_yield_from_in_annotation, + r#" +from typing import reveal_type +def test(): + x: (yield from [1]) # E: + "#, +); From 143a3b0ad4a0ae467184b080e21b99c1dfd8594d Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Mon, 24 Nov 2025 11:46:11 -0800 Subject: [PATCH 39/62] add tests for last diff Summary: see title Reviewed By: yangdanny97 Differential Revision: D87788532 fbshipit-source-id: b76375ffdacaa2a661c834aca3b6facf10cc71b1 --- crates/pyrefly_python/src/docstring.rs | 233 +++++++++++++++++++++++++ pyrefly/lib/test/lsp/hover.rs | 84 +++++++++ pyrefly/lib/test/lsp/signature_help.rs | 215 +++++++++++++++++++++++ 3 files changed, 532 insertions(+) diff --git a/crates/pyrefly_python/src/docstring.rs b/crates/pyrefly_python/src/docstring.rs index 0ff757ae72..d5ae77d227 100644 --- a/crates/pyrefly_python/src/docstring.rs +++ b/crates/pyrefly_python/src/docstring.rs @@ -450,4 +450,237 @@ Args: assert_eq!(docs.get("foo").unwrap(), "first line\nsecond line"); assert_eq!(docs.get("bar").unwrap(), "final"); } + + #[test] + fn test_parse_sphinx_empty_param() { + let doc = r#" +:param foo: +:param bar: has description +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo"), None); // Empty params should not be included + assert_eq!(docs.get("bar").unwrap(), "has description"); + } + + #[test] + fn test_parse_sphinx_with_type_annotations() { + let doc = r#" +:param int foo: an integer +:param str bar: a string +:param Optional[Dict[str, int]] baz: complex type +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "an integer"); + assert_eq!(docs.get("bar").unwrap(), "a string"); + assert_eq!(docs.get("baz").unwrap(), "complex type"); + } + + #[test] + fn test_parse_sphinx_multiple_continuation_lines() { + let doc = r#" +:param foo: line one + line two + line three + line four +:param bar: single line +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!( + docs.get("foo").unwrap(), + "line one\nline two\nline three\nline four" + ); + assert_eq!(docs.get("bar").unwrap(), "single line"); + } + + #[test] + fn test_parse_sphinx_with_other_directives() { + let doc = r#" +:param foo: the foo parameter +:param bar: the bar parameter +:return: the return value +:raises ValueError: when invalid +:type foo: int +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "the foo parameter"); + assert_eq!(docs.get("bar").unwrap(), "the bar parameter"); + // Other directives should not be included as parameters + assert_eq!(docs.get("return"), None); + assert_eq!(docs.get("raises"), None); + } + + #[test] + fn test_parse_sphinx_with_varargs() { + let doc = r#" +:param *args: positional arguments +:param **kwargs: keyword arguments +:param regular: regular param +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("args").unwrap(), "positional arguments"); + assert_eq!(docs.get("kwargs").unwrap(), "keyword arguments"); + assert_eq!(docs.get("regular").unwrap(), "regular param"); + } + + #[test] + fn test_parse_sphinx_indented_in_docstring() { + let doc = r#""" + Summary of function. + + :param foo: first param + with continuation + :param bar: second param + """#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "first param\nwith continuation"); + assert_eq!(docs.get("bar").unwrap(), "second param"); + } + + #[test] + fn test_parse_google_different_headers() { + let doc = r#" +Arguments: + foo: using Arguments header + bar: second arg + +def another_func(): + """ + Keyword Arguments: + baz: keyword arg + """ +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "using Arguments header"); + assert_eq!(docs.get("bar").unwrap(), "second arg"); + } + + #[test] + fn test_parse_google_parameters_header() { + let doc = r#" +Parameters: + foo (int): first param + bar (str): second param +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "first param"); + assert_eq!(docs.get("bar").unwrap(), "second param"); + } + + #[test] + fn test_parse_google_keyword_args_header() { + let doc = r#" +Keyword Args: + foo: keyword arg one + bar: keyword arg two +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "keyword arg one"); + assert_eq!(docs.get("bar").unwrap(), "keyword arg two"); + } + + #[test] + fn test_parse_google_deeply_indented_continuation() { + let doc = r#" +Args: + foo: first line + second line + third line deeply indented + fourth line + bar: simple +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!( + docs.get("foo").unwrap(), + "first line\nsecond line\nthird line deeply indented\nfourth line" + ); + assert_eq!(docs.get("bar").unwrap(), "simple"); + } + + #[test] + fn test_parse_google_no_type_annotation() { + let doc = r#" +Args: + foo: no type annotation + bar (int): with type annotation + baz: also no type +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "no type annotation"); + assert_eq!(docs.get("bar").unwrap(), "with type annotation"); + assert_eq!(docs.get("baz").unwrap(), "also no type"); + } + + #[test] + fn test_parse_google_complex_type_annotations() { + let doc = r#" +Args: + foo (Optional[List[Dict[str, int]]]): complex type + bar (Callable[[int, str], bool]): callable type +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "complex type"); + assert_eq!(docs.get("bar").unwrap(), "callable type"); + } + + #[test] + fn test_parse_google_section_ends_with_other_section() { + let doc = r#" +Args: + foo: the foo param + bar: the bar param + +Returns: + int: the return value + +Raises: + ValueError: when things go wrong +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "the foo param"); + assert_eq!(docs.get("bar").unwrap(), "the bar param"); + // Returns and Raises should not be parsed as params + assert_eq!(docs.len(), 2); + } + + #[test] + fn test_parse_google_empty_parameter_description() { + let doc = r#" +Args: + foo: + bar: has description +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo"), None); // Empty description should not be included + assert_eq!(docs.get("bar").unwrap(), "has description"); + } + + #[test] + fn test_parse_mixed_sphinx_and_google() { + let doc = r#" +:param sphinx_param: using Sphinx style + with continuation + +Args: + google_param: using Google style + another_google: second Google param +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!( + docs.get("sphinx_param").unwrap(), + "using Sphinx style\nwith continuation" + ); + assert_eq!(docs.get("google_param").unwrap(), "using Google style"); + assert_eq!(docs.get("another_google").unwrap(), "second Google param"); + } + + #[test] + fn test_parse_sphinx_param_with_comma_in_type() { + let doc = r#" +:param Dict[str, int] foo: dict param +:param Tuple[int, str, bool] bar: tuple param +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "dict param"); + assert_eq!(docs.get("bar").unwrap(), "tuple param"); + } } diff --git a/pyrefly/lib/test/lsp/hover.rs b/pyrefly/lib/test/lsp/hover.rs index b80a16f8b9..747086d555 100644 --- a/pyrefly/lib/test/lsp/hover.rs +++ b/pyrefly/lib/test/lsp/hover.rs @@ -230,6 +230,90 @@ foo(x=1, y=2) assert!(report.contains("documentation for y")); } +#[test] +fn hover_shows_parameter_doc_with_multiline_description() { + let code = r#" +def foo(param: int) -> None: + """ + Args: + param: This is a long parameter description + that spans multiple lines + with detailed information + """ + ... + +foo(param=1) +# ^ +"#; + let report = get_batched_lsp_operations_report(&[("main", code)], get_test_report); + assert!(report.contains("**Parameter `param`**")); + assert!(report.contains("This is a long parameter description")); + assert!(report.contains("that spans multiple lines")); + assert!(report.contains("with detailed information")); +} + +#[test] +fn hover_on_parameter_definition_shows_doc() { + let code = r#" +def foo(param: int) -> None: + """ + Args: + param: documentation for param + """ + print(param) +# ^ +"#; + let report = get_batched_lsp_operations_report(&[("main", code)], get_test_report); + assert!( + report.contains("**Parameter `param`**"), + "Expected parameter doc when hovering on parameter usage, got: {report}" + ); + assert!(report.contains("documentation for param")); +} + +#[test] +fn hover_parameter_doc_with_type_annotations_in_docstring() { + let code = r#" +def foo(x, y): + """ + Args: + x (int): an integer parameter + y (str): a string parameter + """ + ... + +foo(x=1, y="hello") +# ^ +foo(x=1, y="hello") +# ^ +"#; + let report = get_batched_lsp_operations_report(&[("main", code)], get_test_report); + assert!(report.contains("**Parameter `x`**")); + assert!(report.contains("an integer parameter")); + assert!(report.contains("**Parameter `y`**")); + assert!(report.contains("a string parameter")); +} + +#[test] +fn hover_parameter_doc_with_complex_types() { + let code = r#" +from typing import Optional, List, Dict + +def foo(data: Optional[List[Dict[str, int]]]) -> None: + """ + Args: + data: complex nested type parameter + """ + ... + +foo(data=[]) +# ^ +"#; + let report = get_batched_lsp_operations_report(&[("main", code)], get_test_report); + assert!(report.contains("**Parameter `data`**")); + assert!(report.contains("complex nested type parameter")); +} + #[test] fn hover_over_overloaded_binary_operator_shows_dunder_name() { let code = r#" diff --git a/pyrefly/lib/test/lsp/signature_help.rs b/pyrefly/lib/test/lsp/signature_help.rs index a98ace4eef..d4c33bfd49 100644 --- a/pyrefly/lib/test/lsp/signature_help.rs +++ b/pyrefly/lib/test/lsp/signature_help.rs @@ -539,3 +539,218 @@ Signature Help Result: active=0 report.trim(), ); } + +#[test] +fn parameter_documentation_only_some_params_documented_test() { + let code = r#" +def foo(a: int, b: str, c: bool) -> None: + """ + Args: + a: only a is documented + """ + pass + +foo(a=1, b="", c=True) +# ^ +"#; + let files = [("main", code)]; + let (handles, state) = mk_multi_file_state(&files, Require::indexing(), true); + let handle = handles.get("main").unwrap(); + let position = extract_cursors_for_test(code)[0]; + let signature = state + .transaction() + .get_signature_help_at(handle, position) + .expect("signature help available"); + let params = signature.signatures[0] + .parameters + .as_ref() + .expect("parameters available"); + + // Parameter 'a' should have documentation + let param_a_doc = params + .iter() + .find( + |param| matches!(¶m.label, ParameterLabel::Simple(label) if label.starts_with("a")), + ) + .and_then(|param| param.documentation.as_ref()) + .expect("parameter a documentation"); + if let Documentation::MarkupContent(content) = param_a_doc { + assert_eq!(content.value, "only a is documented"); + } else { + panic!("unexpected documentation variant"); + } + + // Parameter 'b' should not have documentation + let param_b = params + .iter() + .find( + |param| matches!(¶m.label, ParameterLabel::Simple(label) if label.starts_with("b")), + ) + .expect("parameter b should exist"); + assert!( + param_b.documentation.is_none(), + "parameter b should not have documentation" + ); +} + +#[test] +fn parameter_documentation_overloaded_function_test() { + let code = r#" +from typing import overload + +@overload +def foo(a: str) -> bool: + """ + Args: + a: string argument + """ + ... + +@overload +def foo(a: int, b: bool) -> str: + """ + Args: + a: integer argument + b: boolean argument + """ + ... + +def foo(a, b=None): + pass + +foo(1, True) +# ^ +"#; + let files = [("main", code)]; + let (handles, state) = mk_multi_file_state(&files, Require::indexing(), false); + let handle = handles.get("main").unwrap(); + let position = extract_cursors_for_test(code)[0]; + let signature = state + .transaction() + .get_signature_help_at(handle, position) + .expect("signature help available"); + + // Should have multiple signatures + assert!( + signature.signatures.len() >= 2, + "Expected at least 2 overloaded signatures" + ); + + // Each signature should have valid structure + // Parameter docs are optional but when present should be valid + for sig in &signature.signatures { + if let Some(params) = &sig.parameters { + for param in params { + // Documentation is optional but should be valid if present + if let Some(doc) = ¶m.documentation { + assert!(matches!(doc, Documentation::MarkupContent(_))); + } + } + } + } +} + +#[test] +fn parameter_documentation_method_test() { + let code = r#" +class Foo: + def method(self, x: int, y: str) -> None: + """ + Args: + x: the x parameter + y: the y parameter + """ + pass + +foo = Foo() +foo.method(x=1, y="test") +# ^ +"#; + let files = [("main", code)]; + let (handles, state) = mk_multi_file_state(&files, Require::indexing(), true); + let handle = handles.get("main").unwrap(); + let position = extract_cursors_for_test(code)[0]; + let signature = state + .transaction() + .get_signature_help_at(handle, position) + .expect("signature help available"); + let params = signature.signatures[0] + .parameters + .as_ref() + .expect("parameters available"); + + // Should not include 'self' in parameters + let has_self = params.iter().any( + |param| matches!(¶m.label, ParameterLabel::Simple(label) if label.contains("self")), + ); + assert!( + !has_self, + "self parameter should not be in the parameters list" + ); + + // Check if parameter x exists (documentation may or may not be present depending on implementation) + let param_x = params + .iter() + .find( + |param| matches!(¶m.label, ParameterLabel::Simple(label) if label.starts_with("x")), + ) + .expect("parameter x should exist"); + + // If documentation is present, verify it's correct + if let Some(Documentation::MarkupContent(content)) = ¶m_x.documentation { + assert_eq!(content.value, "the x parameter"); + } +} + +#[test] +fn parameter_documentation_mixed_style_test() { + let code = r#" +def foo(a: int, b: str, c: bool) -> None: + """ + :param a: sphinx style + + Args: + b: google style + c: also google style + """ + pass + +foo(a=1, b="", c=True) +# ^ +"#; + let files = [("main", code)]; + let (handles, state) = mk_multi_file_state(&files, Require::indexing(), true); + let handle = handles.get("main").unwrap(); + let position = extract_cursors_for_test(code)[0]; + let signature = state + .transaction() + .get_signature_help_at(handle, position) + .expect("signature help available"); + let params = signature.signatures[0] + .parameters + .as_ref() + .expect("parameters available"); + + // Should have documentation for all three parameters (mixed style should work) + let param_a_doc = params + .iter() + .find( + |param| matches!(¶m.label, ParameterLabel::Simple(label) if label.starts_with("a")), + ) + .and_then(|param| param.documentation.as_ref()) + .expect("parameter a documentation"); + if let Documentation::MarkupContent(content) = param_a_doc { + assert_eq!(content.value, "sphinx style"); + } + + let param_b_doc = params + .iter() + .find( + |param| matches!(¶m.label, ParameterLabel::Simple(label) if label.starts_with("b")), + ) + .and_then(|param| param.documentation.as_ref()) + .expect("parameter b documentation"); + if let Documentation::MarkupContent(content) = param_b_doc { + assert_eq!(content.value, "google style"); + } +} From 7c98ce626b55418515e8feb7080e5c966f396859 Mon Sep 17 00:00:00 2001 From: Karman Singh Date: Tue, 25 Nov 2025 01:52:21 +0530 Subject: [PATCH 40/62] refactor: update `get_callables_from_call` destructuring to include an additional ignored return value. --- pyrefly/lib/state/lsp.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/pyrefly/lib/state/lsp.rs b/pyrefly/lib/state/lsp.rs index f289c4dd0f..2058ba03e9 100644 --- a/pyrefly/lib/state/lsp.rs +++ b/pyrefly/lib/state/lsp.rs @@ -2387,7 +2387,6 @@ impl<'a> Transaction<'a> { } } } - if let Some((callables, chosen_overload_index, active_argument, _)) = self.get_callables_from_call(handle, position) && let Some(callable) = callables.get(chosen_overload_index) From ad4e36175eed03d7f770d0bfaaac9026ad1945e0 Mon Sep 17 00:00:00 2001 From: Karman Singh Date: Tue, 25 Nov 2025 02:16:34 +0530 Subject: [PATCH 41/62] Refactor add_literal_completions to remove in_string check --- pyrefly/lib/state/lsp.rs | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/pyrefly/lib/state/lsp.rs b/pyrefly/lib/state/lsp.rs index 2058ba03e9..157b6bd862 100644 --- a/pyrefly/lib/state/lsp.rs +++ b/pyrefly/lib/state/lsp.rs @@ -2357,36 +2357,12 @@ impl<'a> Transaction<'a> { )) } - fn add_literal_completions( +fn add_literal_completions( &self, handle: &Handle, position: TextSize, completions: &mut Vec, ) { - let mut in_string = false; - if let Some(module_info) = self.get_module_info(handle) { - let source = module_info.contents(); - match parse_module(source) { - Ok(parsed) => { - for token in parsed.tokens() { - let range = token.range(); - if range.contains(position) || (range.end() == position && token.kind() == TokenKind::String) { - if token.kind() == TokenKind::String { - in_string = true; - } - break; - } - } - } - Err(e) => { - if let ParseErrorType::Lexical(LexicalErrorType::UnclosedStringError) = e.error { - if e.location.start() < position { - in_string = true; - } - } - } - } - } if let Some((callables, chosen_overload_index, active_argument, _)) = self.get_callables_from_call(handle, position) && let Some(callable) = callables.get(chosen_overload_index) @@ -2395,7 +2371,7 @@ impl<'a> Transaction<'a> { && let Some(arg_index) = Self::active_parameter_index(¶ms, &active_argument) && let Some(param) = params.get(arg_index) { - Self::add_literal_completions_from_type(param.as_type(), completions, in_string); + Self::add_literal_completions_from_type(param.as_type(), completions); } } From 64a4af20f6ecbd48289462dfbf87f51082d6c008 Mon Sep 17 00:00:00 2001 From: Karman Singh Date: Tue, 25 Nov 2025 12:40:41 +0530 Subject: [PATCH 42/62] added missing call --- pyrefly/lib/state/lsp.rs | 1 + pyrefly/lib/test/lsp/mod.rs | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/pyrefly/lib/state/lsp.rs b/pyrefly/lib/state/lsp.rs index ed46715f01..13ba039f63 100644 --- a/pyrefly/lib/state/lsp.rs +++ b/pyrefly/lib/state/lsp.rs @@ -2757,6 +2757,7 @@ fn add_literal_completions( self.add_magic_method_completions(&identifier, &mut result); } self.add_kwargs_completions(handle, position, &mut result); + self.add_literal_completions(handle, position, &mut result); self.add_keyword_completions(handle, &mut result); let has_local_completions = self.add_local_variable_completions( handle, diff --git a/pyrefly/lib/test/lsp/mod.rs b/pyrefly/lib/test/lsp/mod.rs index 4f605d7ee6..dd5398f930 100644 --- a/pyrefly/lib/test/lsp/mod.rs +++ b/pyrefly/lib/test/lsp/mod.rs @@ -9,7 +9,6 @@ mod code_actions; mod completion; - mod declaration; mod definition; mod diagnostic; From a4f50d49759b9bb1109561c48dedd612830f9ab8 Mon Sep 17 00:00:00 2001 From: Karman Singh Date: Thu, 27 Nov 2025 09:35:10 +0530 Subject: [PATCH 43/62] updated the duplicate quote test --- pyrefly/lib/test/lsp/completion.rs | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/pyrefly/lib/test/lsp/completion.rs b/pyrefly/lib/test/lsp/completion.rs index 1e250786b0..3b15b22ce6 100644 --- a/pyrefly/lib/test/lsp/completion.rs +++ b/pyrefly/lib/test/lsp/completion.rs @@ -1384,19 +1384,26 @@ Completion Results: #[test] fn completion_literal_do_not_duplicate_quotes() { let code = r#" -from typing import Literal -def foo(fruit: Literal["apple", "pear"]) -> None: ... -foo(' +from typing import Literal, Union +class Foo: ... +def foo(x: Union[Union[Literal['foo']] | Literal[1] | Foo]): ... +foo('' # ^ "#; let report = get_batched_lsp_operations_report_allow_error(&[("main", code)], get_default_test_report()); - - // We expect the completion to NOT insert extra quotes if we are already in a quote. - // Currently it likely inserts quotes. - println!("{}", report); - assert!(report.contains("inserting `apple`"), "Should insert unquoted apple"); - assert!(report.contains("inserting `pear`"), "Should insert unquoted pear"); + assert_eq!( + r#" +# main.py +5 | foo('' + ^ +Completion Results: +- (Value) 'foo': Literal['foo'] +- (Value) 1: Literal[1] +"# + .trim(), + report.trim(), + ); } // todo(kylei): completion on known dict values From 935f9bc5d90be28526ac7785c8d554cedfc01af0 Mon Sep 17 00:00:00 2001 From: Danny Yang Date: Tue, 25 Nov 2025 11:31:16 -0800 Subject: [PATCH 44/62] add optional display name to Type::Union Summary: This is an alternative to D84849556, which aims to solve https://github.com/facebook/pyrefly/issues/1219 Instead of introducing a new type variant, this diff introduces a new field in Type::Union to hold an optional display name. It will be populated when resolving a type alias. Compared to the other approach: benefits - less plumbing - type checking behavior unaffected drawbacks - only applies to type aliases of unions (though arguably this is where readability matters most) - name information is not preserved during union flattening (so if we union a type alias with something else, the result is the full expanded type) Reviewed By: rchen152 Differential Revision: D87558541 fbshipit-source-id: d032c7467a0e7b02a2b20074424113472cc78a74 --- crates/pyrefly_types/src/display.rs | 15 +++++++-------- crates/pyrefly_types/src/types.rs | 6 +----- pyrefly/lib/solver/solver.rs | 13 ++----------- 3 files changed, 10 insertions(+), 24 deletions(-) diff --git a/crates/pyrefly_types/src/display.rs b/crates/pyrefly_types/src/display.rs index 8fbf6e08d1..05d7ab2e26 100644 --- a/crates/pyrefly_types/src/display.rs +++ b/crates/pyrefly_types/src/display.rs @@ -471,8 +471,11 @@ impl<'a> TypeDisplayContext<'a> { Type::Union(box Union { display_name: Some(name), .. - }) if !is_toplevel => output.write_str(name), - Type::Union(box Union { members, .. }) => { + }) => output.write_str(name), + Type::Union(box Union { + members, + display_name: None, + }) => { let mut literal_idx = None; let mut literals = Vec::new(); let mut union_members: Vec<&Type> = Vec::new(); @@ -1211,12 +1214,8 @@ pub mod tests { "None | Literal[True, 'test'] | LiteralString" ); assert_eq!( - Type::type_form(Type::Union(Box::new(Union { - members: vec![nonlit1, nonlit2], - display_name: Some("MyUnion".to_owned()) - }))) - .to_string(), - "type[MyUnion]" + Type::union(vec![nonlit1, nonlit2]).to_string(), + "None | LiteralString" ); } diff --git a/crates/pyrefly_types/src/types.rs b/crates/pyrefly_types/src/types.rs index 692f5b4b11..dae5de804a 100644 --- a/crates/pyrefly_types/src/types.rs +++ b/crates/pyrefly_types/src/types.rs @@ -1474,11 +1474,7 @@ impl Type { pub fn sort_unions_and_drop_names(self) -> Self { self.transform(&mut |ty| { - if let Type::Union(box Union { - members: ts, - display_name, - }) = ty - { + if let Type::Union(box Union { members: ts, .. }) = ty { ts.sort(); *display_name = None; } diff --git a/pyrefly/lib/solver/solver.rs b/pyrefly/lib/solver/solver.rs index 9e5d2ceb13..32d64507da 100644 --- a/pyrefly/lib/solver/solver.rs +++ b/pyrefly/lib/solver/solver.rs @@ -457,17 +457,8 @@ impl Solver { /// Simplify a type as much as we can. fn simplify_mut(&self, t: &mut Type) { t.transform_mut(&mut |x| { - if let Type::Union(box Union { - members: xs, - display_name: original_name, - }) = x - { - let mut merged = unions(mem::take(xs)); - // Preserve union display names during simplification - if let Type::Union(box Union { display_name, .. }) = &mut merged { - *display_name = original_name.clone(); - } - *x = merged; + if let Type::Union(box Union { members: xs, .. }) = x { + *x = unions(mem::take(xs)); } if let Type::Intersect(y) = x { *x = intersect(mem::take(&mut y.0), y.1.clone()); From 65aef3dc802124cc21ea643140b8dec185cd6f11 Mon Sep 17 00:00:00 2001 From: Danny Yang Date: Wed, 26 Nov 2025 10:37:55 -0800 Subject: [PATCH 45/62] bump ruff packages Summary: X-link: https://github.com/meta-pytorch/monarch/pull/1997 Need to pull in a ruff parser change to fix https://github.com/facebook/pyrefly/issues/1559 This requires a bump for the `inventory` crate, since ruff requires 0.3.20/0.3.21. That version is semver compatible with 0.3.8, so we can't keep both versions around. Reviewed By: dtolnay Differential Revision: D87817697 fbshipit-source-id: 7bb25dc7853161b946283288e40a06dd8d067119 --- crates/pyrefly_python/Cargo.toml | 2 +- pyrefly/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/pyrefly_python/Cargo.toml b/crates/pyrefly_python/Cargo.toml index 2262f83268..f2891e39c9 100644 --- a/crates/pyrefly_python/Cargo.toml +++ b/crates/pyrefly_python/Cargo.toml @@ -19,7 +19,7 @@ lsp-types = { git = "https://github.com/astral-sh/lsp-types", rev = "3512a9f33ea parse-display = "0.8.2" pathdiff = "0.2" pyrefly_util = { path = "../pyrefly_util" } -regex = "1.12.2" +regex = "1.11.1" ruff_notebook = { git = "https://github.com/astral-sh/ruff/", rev = "474b00568ad78f02ad8e19b8166cbeb6d69f8511" } ruff_python_ast = { git = "https://github.com/astral-sh/ruff/", rev = "474b00568ad78f02ad8e19b8166cbeb6d69f8511" } ruff_python_parser = { git = "https://github.com/astral-sh/ruff/", rev = "474b00568ad78f02ad8e19b8166cbeb6d69f8511" } diff --git a/pyrefly/Cargo.toml b/pyrefly/Cargo.toml index f7d5218b2f..86a69eb094 100644 --- a/pyrefly/Cargo.toml +++ b/pyrefly/Cargo.toml @@ -41,7 +41,7 @@ pyrefly_python = { path = "../crates/pyrefly_python" } pyrefly_types = { path = "../crates/pyrefly_types" } pyrefly_util = { path = "../crates/pyrefly_util" } rayon = "1.11.0" -regex = "1.12.2" +regex = "1.11.1" ruff_annotate_snippets = { git = "https://github.com/astral-sh/ruff/", rev = "474b00568ad78f02ad8e19b8166cbeb6d69f8511" } ruff_notebook = { git = "https://github.com/astral-sh/ruff/", rev = "474b00568ad78f02ad8e19b8166cbeb6d69f8511" } ruff_python_ast = { git = "https://github.com/astral-sh/ruff/", rev = "474b00568ad78f02ad8e19b8166cbeb6d69f8511", features = ["serde"] } From acc00ad0982805146242de80a5fcbbe41a1df298 Mon Sep 17 00:00:00 2001 From: Karman Singh Date: Sat, 22 Nov 2025 16:53:55 +0530 Subject: [PATCH 46/62] feat: Improve LSP literal completions by removing quotes when inserting into existing strings and enhance error message normalization. --- pyrefly/lib/error/expectation.rs | 2 +- pyrefly/lib/state/lsp.rs | 8 +- pyrefly/lib/test/lsp/completion_quotes.rs | 104 ++++++++++++++++++++++ pyrefly/lib/test/lsp/mod.rs | 1 + 4 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 pyrefly/lib/test/lsp/completion_quotes.rs diff --git a/pyrefly/lib/error/expectation.rs b/pyrefly/lib/error/expectation.rs index 44a7f905f5..0e863895f1 100644 --- a/pyrefly/lib/error/expectation.rs +++ b/pyrefly/lib/error/expectation.rs @@ -65,6 +65,6 @@ impl Expectation { fn normalize_message(s: &str) -> String { s.replace("\\'", "'") // unescape single quotes - // keep double-quote escapes as-is + .replace("\"", "\"") // keep double-quote escapes as-is .replace("''", "'") // collapse doubled single quotes } diff --git a/pyrefly/lib/state/lsp.rs b/pyrefly/lib/state/lsp.rs index 7cffe0c590..120daef16c 100644 --- a/pyrefly/lib/state/lsp.rs +++ b/pyrefly/lib/state/lsp.rs @@ -2423,7 +2423,7 @@ fn add_literal_completions( && let Some(arg_index) = Self::active_parameter_index(¶ms, &active_argument) && let Some(param) = params.get(arg_index) { - Self::add_literal_completions_from_type(param.as_type(), completions); + Self::add_literal_completions_from_type(param.as_type(), completions, in_string); } } @@ -2452,9 +2452,9 @@ fn add_literal_completions( ..Default::default() }); } - Type::Union(box Union { members, .. }) => { - for member in members { - Self::add_literal_completions_from_type(member, completions); + Type::Union(types) => { + for union_type in types { + Self::add_literal_completions_from_type(union_type, completions, in_string); } } _ => {} diff --git a/pyrefly/lib/test/lsp/completion_quotes.rs b/pyrefly/lib/test/lsp/completion_quotes.rs new file mode 100644 index 0000000000..66af7efe90 --- /dev/null +++ b/pyrefly/lib/test/lsp/completion_quotes.rs @@ -0,0 +1,104 @@ + +use lsp_types::CompletionItem; +use lsp_types::CompletionItemKind; +use pretty_assertions::assert_eq; +use pyrefly_build::handle::Handle; +use ruff_text_size::TextSize; + +use crate::state::lsp::ImportFormat; +use crate::state::require::Require; +use crate::state::state::State; +use crate::test::util::get_batched_lsp_operations_report_allow_error; + +#[derive(Default)] +struct ResultsFilter { + include_keywords: bool, + include_builtins: bool, +} + +fn get_default_test_report() -> impl Fn(&State, &Handle, TextSize) -> String { + get_test_report(ResultsFilter::default(), ImportFormat::Absolute) +} + +fn get_test_report( + filter: ResultsFilter, + import_format: ImportFormat, +) -> impl Fn(&State, &Handle, TextSize) -> String { + move |state: &State, handle: &Handle, position: TextSize| { + let mut report = "Completion Results:".to_owned(); + for CompletionItem { + label, + detail, + kind, + insert_text, + data, + tags, + text_edit, + documentation, + .. + } in state + .transaction() + .completion(handle, position, import_format, true) + { + let is_deprecated = if let Some(tags) = tags { + tags.contains(&lsp_types::CompletionItemTag::DEPRECATED) + } else { + false + }; + if (filter.include_keywords || kind != Some(CompletionItemKind::KEYWORD)) + && (filter.include_builtins || data != Some(serde_json::json!("builtin"))) + { + report.push_str("\n- ("); + report.push_str(&format!("{:?}", kind.unwrap())); + report.push_str(") "); + if is_deprecated { + report.push_str("[DEPRECATED] "); + } + report.push_str(&label); + if let Some(detail) = detail { + report.push_str(": "); + report.push_str(&detail); + } + if let Some(insert_text) = insert_text { + report.push_str(" inserting `"); + report.push_str(&insert_text); + report.push('`'); + } + if let Some(text_edit) = text_edit { + report.push_str(" with text edit: "); + report.push_str(&format!("{:?}", &text_edit)); + } + if let Some(documentation) = documentation { + report.push('\n'); + match documentation { + lsp_types::Documentation::String(s) => { + report.push_str(&s); + } + lsp_types::Documentation::MarkupContent(content) => { + report.push_str(&content.value); + } + } + } + } + } + report + } +} + +#[test] +fn completion_literal_quote_test() { + let code = r#" +from typing import Literal +def foo(fruit: Literal["apple", "pear"]) -> None: ... +foo(' +# ^ +"#; + let report = + get_batched_lsp_operations_report_allow_error(&[("main", code)], get_default_test_report()); + + // We expect the completion to NOT insert extra quotes if we are already in a quote. + // Currently it likely inserts quotes. + println!("{}", report); + assert!(report.contains("inserting `apple`"), "Should insert unquoted apple"); + assert!(report.contains("inserting `pear`"), "Should insert unquoted pear"); +} diff --git a/pyrefly/lib/test/lsp/mod.rs b/pyrefly/lib/test/lsp/mod.rs index dd5398f930..8040c6b303 100644 --- a/pyrefly/lib/test/lsp/mod.rs +++ b/pyrefly/lib/test/lsp/mod.rs @@ -9,6 +9,7 @@ mod code_actions; mod completion; +mod completion_quotes; mod declaration; mod definition; mod diagnostic; From f00a366e6e4d779bf851e9e13fee8def727e3195 Mon Sep 17 00:00:00 2001 From: Karman Singh Date: Sat, 22 Nov 2025 23:29:52 +0530 Subject: [PATCH 47/62] test: add 'inserting' field to expected LSP completion results for literal values --- pyrefly/lib/test/lsp/completion.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pyrefly/lib/test/lsp/completion.rs b/pyrefly/lib/test/lsp/completion.rs index 93ff84db55..3cd2bed260 100644 --- a/pyrefly/lib/test/lsp/completion.rs +++ b/pyrefly/lib/test/lsp/completion.rs @@ -1248,7 +1248,9 @@ foo(" 4 | foo(" ^ Completion Results: -- (Value) 'a\nb': Literal['a\nb']"# +- (Value) 'a\nb': Literal['a\nb'] inserting `a +b` +- (Variable) x=: Literal['a\nb']"# .trim(), report.trim(), ); @@ -1295,8 +1297,9 @@ foo(' 4 | foo(' ^ Completion Results: -- (Value) 'bar': Literal['bar'] -- (Value) 'foo': Literal['foo'] +- (Value) 'bar': Literal['bar'] inserting `bar` +- (Value) 'foo': Literal['foo'] inserting `foo` +- (Variable) x=: Literal['bar', 'foo'] "# .trim(), report.trim(), From 5c8d4f98667c236c220ce7db03fbcc5abf3bef43 Mon Sep 17 00:00:00 2001 From: Steven Troxler Date: Sat, 22 Nov 2025 11:13:50 -0800 Subject: [PATCH 48/62] Fix linter warnings for cargo Summary: I think `cargo` just got a clippy version bump, I was getting pages of linter errors, almost all of them about elided lifetimes. This silences them. Reviewed By: rchen152 Differential Revision: D87653409 fbshipit-source-id: 0e588fb5363e0244226758ded1965b3f4d8a2333 --- pyrefly/lib/test/lsp/lsp_interaction/object_model.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyrefly/lib/test/lsp/lsp_interaction/object_model.rs b/pyrefly/lib/test/lsp/lsp_interaction/object_model.rs index 4fd8da860f..4f7b1a8b3d 100644 --- a/pyrefly/lib/test/lsp/lsp_interaction/object_model.rs +++ b/pyrefly/lib/test/lsp/lsp_interaction/object_model.rs @@ -739,7 +739,7 @@ impl TestClient { pub fn expect_request( &self, expected: Value, - ) -> Result, LspMessageError> { + ) -> ServerRequestHandle<'_, R> { // Validate that expected can be parsed as R::Params let expected: R::Params = serde_json::from_value(expected.clone()).unwrap(); let id = self.expect_message(&format!("Request {}", R::METHOD), |msg| { @@ -991,7 +991,7 @@ impl TestClient { pub fn expect_configuration_request( &self, scope_uris: Option>, - ) -> Result, LspMessageError> { + ) -> ServerRequestHandle<'_, WorkspaceConfiguration> { let items = scope_uris .unwrap_or_default() .into_iter() From c781bfa7c3efc6977a1711261b5f0696c02002b9 Mon Sep 17 00:00:00 2001 From: Steven Troxler Date: Sat, 22 Nov 2025 11:13:50 -0800 Subject: [PATCH 49/62] Just pass down inferred_from_method as a boolean flag Summary: We want to get rid of RawClassFieldInitialization - it's unnecessary once we've refactored the code to make more sense. As a result, it's important to avoid depending on it where possible; in this case, let's keep the match in `calculate_class_field` (which is where we need to refactor) and just pass a flag down. Reviewed By: rchen152 Differential Revision: D87653414 fbshipit-source-id: 030b4e353d867f6cd9c5696bc5f80ee9610b0f93 --- pyrefly/lib/alt/class/class_field.rs | 258 ++++++++------------------- 1 file changed, 70 insertions(+), 188 deletions(-) diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index d6e62c0f98..b0f8fe9833 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -1072,201 +1072,83 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { let value_storage = Owner::new(); let initial_value_storage = Owner::new(); let direct_annotation = self.annotation_of_field_definition(field_definition); - - let ( - initial_value, - is_function_without_return_annotation, - value_ty, - inherited_annotation, - is_inherited, - ) = match field_definition { - ClassFieldDefinition::DeclaredByAnnotation { .. } => { - let initial_value = - initial_value_storage.push(RawClassFieldInitialization::Uninitialized); - if let Some(annotated_ty) = - direct_annotation.as_ref().and_then(|ann| ann.ty.clone()) - { - ( - initial_value, - false, - annotated_ty, - None, - if Ast::is_mangled_attr(name) { - IsInherited::No - } else { - IsInherited::Maybe - }, - ) - } else { - let value = value_storage - .push(ExprOrBinding::Binding(Binding::Type(Type::any_implicit()))); - let (value_ty, inherited_annotation, is_inherited) = - self.analyze_class_field_value(value, class, name, false, errors); - ( - initial_value, - false, - value_ty, - inherited_annotation, - is_inherited, - ) - } - } - ClassFieldDefinition::AssignedInBody { value, .. } => { - let initial_value = initial_value_storage.push( - RawClassFieldInitialization::ClassBody(match value { - ExprOrBinding::Expr(e) => Some(e.clone()), - ExprOrBinding::Binding(_) => None, - }), - ); - if let Some(annotated_ty) = - direct_annotation.as_ref().and_then(|ann| ann.ty.clone()) - { - ( - initial_value, - false, - annotated_ty, - None, - if Ast::is_mangled_attr(name) { - IsInherited::No - } else { - IsInherited::Maybe - }, - ) - } else { - let (value_ty, inherited_annotation, is_inherited) = - self.analyze_class_field_value(value, class, name, false, errors); - ( - initial_value, - false, - value_ty, - inherited_annotation, - is_inherited, - ) - } - } - ClassFieldDefinition::DefinedInMethod { value, method, .. } => { - // Check if there's an inherited property field from a parent class - // If so, we should just use the parent's property instead of creating a new field - if !Ast::is_mangled_attr(name) { - // Use get_field_from_ancestors to only look at parent classes, not the current class - if let Some(parent_field) = self.get_field_from_ancestors( - class, - self.get_mro_for_class(class).ancestors(self.stdlib), - name, - &|cls, name| self.get_field_from_current_class_only(cls, name), - ) { - let ClassField(ClassFieldInner::Simple { ty, .. }, ..) = - &*parent_field.value; - // Check if the parent field is a property (either getter or setter with getter) - if ty.is_property_getter() || ty.is_property_setter_with_getter().is_some() - { - // If we found a property in the parent, just return the parent's field - // This ensures the property with its setter is properly inherited - return Arc::unwrap_or_clone(parent_field.value); - } - } - } - - let initial_value = - initial_value_storage.push(RawClassFieldInitialization::Method(method.clone())); - if let Some(annotated_ty) = - direct_annotation.as_ref().and_then(|ann| ann.ty.clone()) - { - ( - initial_value, - false, - annotated_ty, - None, - if Ast::is_mangled_attr(name) { - IsInherited::No - } else { - IsInherited::Maybe - }, - ) - } else { - let (mut value_ty, inherited_annotation, is_inherited) = - self.analyze_class_field_value(value, class, name, true, errors); - if matches!(method.instance_or_class, MethodSelfKind::Instance) { - value_ty = self.check_and_sanitize_type_parameters( - class, value_ty, name, range, errors, - ); - } - ( - initial_value, - false, - value_ty, - inherited_annotation, - is_inherited, - ) - } - } + let (value, initial_value, is_function_without_return_annotation) = match field_definition { + ClassFieldDefinition::DeclaredByAnnotation { .. } => ( + value_storage.push(ExprOrBinding::Binding(Binding::Type(Type::any_implicit()))), + initial_value_storage.push(RawClassFieldInitialization::Uninitialized), + false, + ), + ClassFieldDefinition::AssignedInBody { value, .. } => ( + value, + initial_value_storage.push(RawClassFieldInitialization::ClassBody(match value { + ExprOrBinding::Expr(e) => Some(e.clone()), + ExprOrBinding::Binding(_) => None, + })), + false, + ), + ClassFieldDefinition::DefinedInMethod { value, method, .. } => ( + value, + initial_value_storage.push(RawClassFieldInitialization::Method(method.clone())), + false, + ), ClassFieldDefinition::MethodLike { definition, has_return_annotation, - } => { - let initial_value = - initial_value_storage.push(RawClassFieldInitialization::ClassBody(None)); - let value = - value_storage.push(ExprOrBinding::Binding(Binding::Forward(*definition))); - let (value_ty, inherited_annotation, is_inherited) = - self.analyze_class_field_value(value, class, name, false, errors); - ( - initial_value, - !has_return_annotation, - value_ty, - inherited_annotation, - is_inherited, - ) - } - ClassFieldDefinition::DefinedWithoutAssign { definition } => { - let initial_value = - initial_value_storage.push(RawClassFieldInitialization::ClassBody(None)); - let value = - value_storage.push(ExprOrBinding::Binding(Binding::Forward(*definition))); - let (value_ty, inherited_annotation, is_inherited) = - self.analyze_class_field_value(value, class, name, false, errors); - ( - initial_value, - false, - value_ty, - inherited_annotation, - is_inherited, - ) - } - ClassFieldDefinition::DeclaredWithoutAnnotation => { + } => ( + value_storage.push(ExprOrBinding::Binding(Binding::Forward(*definition))), + initial_value_storage.push(RawClassFieldInitialization::ClassBody(None)), + !has_return_annotation, + ), + ClassFieldDefinition::DefinedWithoutAssign { definition } => ( + value_storage.push(ExprOrBinding::Binding(Binding::Forward(*definition))), + initial_value_storage.push(RawClassFieldInitialization::ClassBody(None)), + false, + ), + ClassFieldDefinition::DeclaredWithoutAnnotation => ( // This is a field in a synthesized class with no information at all, treat it as Any. - let initial_value = - initial_value_storage.push(RawClassFieldInitialization::Uninitialized); - let value = - value_storage.push(ExprOrBinding::Binding(Binding::Type(Type::any_implicit()))); - let (value_ty, inherited_annotation, is_inherited) = - self.analyze_class_field_value(value, class, name, false, errors); + value_storage.push(ExprOrBinding::Binding(Binding::Type(Type::any_implicit()))), + initial_value_storage.push(RawClassFieldInitialization::Uninitialized), + false, + ), + }; + + // Get the inferred value type if the value is an expression + // + // In some cases (non-private method-defined attributes with no direct annotation) we will look for an + // inherited type and annotation because that type is used instead of the inferred type. + // + // We also track `is_inherited`, which is an optimization to skip inheritence checks later when we + // know the attribute isn't inherited. + let (value_ty, inherited_annotation, is_inherited) = + if let Some(annotated_ty) = direct_annotation.as_ref().and_then(|ann| ann.ty.clone()) { + // If there's an annotated type, we can ignore the expression entirely. + // Note that the assignment will still be type checked by the "normal" + // type checking logic, there's no need to duplicate it here. ( - initial_value, - false, - value_ty, - inherited_annotation, - is_inherited, + annotated_ty, + None, + if Ast::is_mangled_attr(name) { + IsInherited::No + } else { + IsInherited::Maybe + }, ) - } - }; + } else { + self.analyze_class_field_value( + value, + class, + name, + matches!(initial_value, RawClassFieldInitialization::Method(..)), + errors, + ) + }; - let initialization = match initial_value { - RawClassFieldInitialization::ClassBody(None) => { - ClassFieldInitialization::ClassBody(None) - } - RawClassFieldInitialization::ClassBody(Some(e)) => { - // If this field was created via a call to a dataclass field specifier, extract field flags from the call. - if let Some(dm) = metadata.dataclass_metadata() - && let Expr::Call(call) = e - { - let flags = self.compute_dataclass_field_initialization(call, dm); - ClassFieldInitialization::ClassBody(flags.map(Box::new)) - } else { - ClassFieldInitialization::ClassBody(None) - } - } - RawClassFieldInitialization::Method(MethodThatSetsAttr { + // A type inferred from a method body can potentially "capture" type annotations that + // are method-scope. We need to complain if this happens and fall back to gradual types. + let value_ty = match initial_value { + RawClassFieldInitialization::ClassBody(_) + | RawClassFieldInitialization::Uninitialized + | RawClassFieldInitialization::Method(MethodThatSetsAttr { instance_or_class: MethodSelfKind::Class, .. }) => ClassFieldInitialization::ClassBody(None), From 3e803c5cabaefc0c4faeffa3ff82f0befc1e11a6 Mon Sep 17 00:00:00 2001 From: Steven Troxler Date: Sat, 22 Nov 2025 11:13:50 -0800 Subject: [PATCH 50/62] Inline `check_and_sanitize_type_parameters` into the main match Summary: There's no reason to do a separate match downstream - this logic is only relevant to one branch of the `field_definition` match. Reviewed By: grievejia Differential Revision: D87653406 fbshipit-source-id: 8e9d5c2555bb929996052df3b8aed8b5893d0ebb --- pyrefly/lib/alt/class/class_field.rs | 248 +++++++++++++++++---------- 1 file changed, 158 insertions(+), 90 deletions(-) diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index b0f8fe9833..8c0530c616 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -1072,105 +1072,173 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { let value_storage = Owner::new(); let initial_value_storage = Owner::new(); let direct_annotation = self.annotation_of_field_definition(field_definition); - let (value, initial_value, is_function_without_return_annotation) = match field_definition { - ClassFieldDefinition::DeclaredByAnnotation { .. } => ( - value_storage.push(ExprOrBinding::Binding(Binding::Type(Type::any_implicit()))), - initial_value_storage.push(RawClassFieldInitialization::Uninitialized), - false, - ), - ClassFieldDefinition::AssignedInBody { value, .. } => ( - value, - initial_value_storage.push(RawClassFieldInitialization::ClassBody(match value { - ExprOrBinding::Expr(e) => Some(e.clone()), - ExprOrBinding::Binding(_) => None, - })), - false, - ), - ClassFieldDefinition::DefinedInMethod { value, method, .. } => ( - value, - initial_value_storage.push(RawClassFieldInitialization::Method(method.clone())), - false, - ), + + let ( + initial_value, + is_function_without_return_annotation, + value_ty, + inherited_annotation, + is_inherited, + ) = match field_definition { + ClassFieldDefinition::DeclaredByAnnotation { .. } => { + let initial_value = + initial_value_storage.push(RawClassFieldInitialization::Uninitialized); + if let Some(annotated_ty) = + direct_annotation.as_ref().and_then(|ann| ann.ty.clone()) + { + ( + initial_value, + false, + annotated_ty, + None, + if Ast::is_mangled_attr(name) { + IsInherited::No + } else { + IsInherited::Maybe + }, + ) + } else { + let value = value_storage + .push(ExprOrBinding::Binding(Binding::Type(Type::any_implicit()))); + let (value_ty, inherited_annotation, is_inherited) = + self.analyze_class_field_value(value, class, name, false, errors); + ( + initial_value, + false, + value_ty, + inherited_annotation, + is_inherited, + ) + } + } + ClassFieldDefinition::AssignedInBody { value, .. } => { + let initial_value = initial_value_storage.push( + RawClassFieldInitialization::ClassBody(match value { + ExprOrBinding::Expr(e) => Some(e.clone()), + ExprOrBinding::Binding(_) => None, + }), + ); + if let Some(annotated_ty) = + direct_annotation.as_ref().and_then(|ann| ann.ty.clone()) + { + ( + initial_value, + false, + annotated_ty, + None, + if Ast::is_mangled_attr(name) { + IsInherited::No + } else { + IsInherited::Maybe + }, + ) + } else { + let (value_ty, inherited_annotation, is_inherited) = + self.analyze_class_field_value(value, class, name, false, errors); + ( + initial_value, + false, + value_ty, + inherited_annotation, + is_inherited, + ) + } + } + ClassFieldDefinition::DefinedInMethod { value, method, .. } => { + let initial_value = + initial_value_storage.push(RawClassFieldInitialization::Method(method.clone())); + if let Some(annotated_ty) = + direct_annotation.as_ref().and_then(|ann| ann.ty.clone()) + { + ( + initial_value, + false, + annotated_ty, + None, + if Ast::is_mangled_attr(name) { + IsInherited::No + } else { + IsInherited::Maybe + }, + ) + } else { + let (mut value_ty, inherited_annotation, is_inherited) = + self.analyze_class_field_value(value, class, name, true, errors); + if matches!(method.instance_or_class, MethodSelfKind::Instance) { + value_ty = self.check_and_sanitize_type_parameters( + class, value_ty, name, range, errors, + ); + } + ( + initial_value, + false, + value_ty, + inherited_annotation, + is_inherited, + ) + } + } ClassFieldDefinition::MethodLike { definition, has_return_annotation, - } => ( - value_storage.push(ExprOrBinding::Binding(Binding::Forward(*definition))), - initial_value_storage.push(RawClassFieldInitialization::ClassBody(None)), - !has_return_annotation, - ), - ClassFieldDefinition::DefinedWithoutAssign { definition } => ( - value_storage.push(ExprOrBinding::Binding(Binding::Forward(*definition))), - initial_value_storage.push(RawClassFieldInitialization::ClassBody(None)), - false, - ), - ClassFieldDefinition::DeclaredWithoutAnnotation => ( - // This is a field in a synthesized class with no information at all, treat it as Any. - value_storage.push(ExprOrBinding::Binding(Binding::Type(Type::any_implicit()))), - initial_value_storage.push(RawClassFieldInitialization::Uninitialized), - false, - ), - }; - - // Get the inferred value type if the value is an expression - // - // In some cases (non-private method-defined attributes with no direct annotation) we will look for an - // inherited type and annotation because that type is used instead of the inferred type. - // - // We also track `is_inherited`, which is an optimization to skip inheritence checks later when we - // know the attribute isn't inherited. - let (value_ty, inherited_annotation, is_inherited) = - if let Some(annotated_ty) = direct_annotation.as_ref().and_then(|ann| ann.ty.clone()) { - // If there's an annotated type, we can ignore the expression entirely. - // Note that the assignment will still be type checked by the "normal" - // type checking logic, there's no need to duplicate it here. + } => { + let initial_value = + initial_value_storage.push(RawClassFieldInitialization::ClassBody(None)); + let value = + value_storage.push(ExprOrBinding::Binding(Binding::Forward(*definition))); + let (value_ty, inherited_annotation, is_inherited) = + self.analyze_class_field_value(value, class, name, false, errors); ( - annotated_ty, - None, - if Ast::is_mangled_attr(name) { - IsInherited::No - } else { - IsInherited::Maybe - }, + initial_value, + !has_return_annotation, + value_ty, + inherited_annotation, + is_inherited, ) - } else { - self.analyze_class_field_value( - value, - class, - name, - matches!(initial_value, RawClassFieldInitialization::Method(..)), - errors, + } + ClassFieldDefinition::DefinedWithoutAssign { definition } => { + let initial_value = + initial_value_storage.push(RawClassFieldInitialization::ClassBody(None)); + let value = + value_storage.push(ExprOrBinding::Binding(Binding::Forward(*definition))); + let (value_ty, inherited_annotation, is_inherited) = + self.analyze_class_field_value(value, class, name, false, errors); + ( + initial_value, + false, + value_ty, + inherited_annotation, + is_inherited, + ) + } + ClassFieldDefinition::DeclaredWithoutAnnotation => { + // This is a field in a synthesized class with no information at all, treat it as Any. + let initial_value = + initial_value_storage.push(RawClassFieldInitialization::Uninitialized); + let value = + value_storage.push(ExprOrBinding::Binding(Binding::Type(Type::any_implicit()))); + let (value_ty, inherited_annotation, is_inherited) = + self.analyze_class_field_value(value, class, name, false, errors); + ( + initial_value, + false, + value_ty, + inherited_annotation, + is_inherited, ) }; - // A type inferred from a method body can potentially "capture" type annotations that - // are method-scope. We need to complain if this happens and fall back to gradual types. - let value_ty = match initial_value { - RawClassFieldInitialization::ClassBody(_) - | RawClassFieldInitialization::Uninitialized - | RawClassFieldInitialization::Method(MethodThatSetsAttr { - instance_or_class: MethodSelfKind::Class, - .. - }) => ClassFieldInitialization::ClassBody(None), - RawClassFieldInitialization::Method(MethodThatSetsAttr { - instance_or_class: MethodSelfKind::Instance, - .. - }) => ClassFieldInitialization::Method, - RawClassFieldInitialization::Uninitialized => { - // We consider fields to be always-initialized if it's defined within stub files. - // See https://github.com/python/typeshed/pull/13875 for reasoning. - if class.module_path().is_interface() - // We consider fields to be always-initialized if it's annotated explicitly with `ClassVar`. - || direct_annotation - .as_ref() - .is_some_and(|annot| annot.has_qualifier(&Qualifier::ClassVar)) - { - ClassFieldInitialization::Magic - } else { - ClassFieldInitialization::Uninitialized - } - } + let magically_initialized = { + // We consider fields to be always-initialized if it's defined within stub files. + // See https://github.com/python/typeshed/pull/13875 for reasoning. + class.module_path().is_interface() + // We consider fields to be always-initialized if it's annotated explicitly with `ClassVar`. + || direct_annotation + .as_ref() + .is_some_and(|annot| annot.has_qualifier(&Qualifier::ClassVar)) }; + let initialization = + self.get_class_field_initialization(&metadata, initial_value, magically_initialized); if let Some(annotation) = direct_annotation.as_ref() { self.validate_direct_annotation( From 5f287751967c6f88103f63bf37389bb2d4091f03 Mon Sep 17 00:00:00 2001 From: Steven Troxler Date: Sat, 22 Nov 2025 11:13:50 -0800 Subject: [PATCH 51/62] Inline get_class_field_initialization Summary: We want `RawClassFieldInitialization` and `initial_value` to go away; these aren't necessary, they are just tech debt accumulated. As a result, we don't want to have a helper function, because that makes it harder to deduplicate and simplify the match statements. In preparation for coming simplifications, let's just inline. This makes things messier in the short term but will pay off as we keep cleaning things up. Reviewed By: grievejia Differential Revision: D87653407 fbshipit-source-id: 79b14889b71534b70bd591f89eca64939a2f7b28 --- pyrefly/lib/alt/class/class_field.rs | 80 ++++++++++++++++++---------- 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/pyrefly/lib/alt/class/class_field.rs b/pyrefly/lib/alt/class/class_field.rs index 8c0530c616..c49b7d93ec 100644 --- a/pyrefly/lib/alt/class/class_field.rs +++ b/pyrefly/lib/alt/class/class_field.rs @@ -1237,8 +1237,57 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { .as_ref() .is_some_and(|annot| annot.has_qualifier(&Qualifier::ClassVar)) }; - let initialization = - self.get_class_field_initialization(&metadata, initial_value, magically_initialized); + let initialization = match initial_value { + RawClassFieldInitialization::ClassBody(None) => { + ClassFieldInitialization::ClassBody(None) + } + RawClassFieldInitialization::ClassBody(Some(e)) => { + // If this field was created via a call to a dataclass field specifier, extract field flags from the call. + if let Some(dm) = metadata.dataclass_metadata() + && let Expr::Call(ExprCall { + node_index: _, + range: _, + func, + arguments, + }) = e + { + // We already type-checked this expression as part of computing the type for the ClassField, + // so we can ignore any errors encountered here. + let ignore_errors = self.error_swallower(); + let func_ty = self.expr_infer(func, &ignore_errors); + let func_kind = func_ty.callee_kind(); + if let Some(func_kind) = func_kind + && dm.field_specifiers.contains(&func_kind) + { + let flags = + self.dataclass_field_keywords(&func_ty, arguments, dm, &ignore_errors); + ClassFieldInitialization::ClassBody(Some(flags)) + } else { + ClassFieldInitialization::ClassBody(None) + } + } else { + ClassFieldInitialization::ClassBody(None) + } + } + RawClassFieldInitialization::Method(MethodThatSetsAttr { + instance_or_class: MethodSelfKind::Class, + .. + }) => ClassFieldInitialization::ClassBody(None), + RawClassFieldInitialization::Method(MethodThatSetsAttr { + instance_or_class: MethodSelfKind::Instance, + .. + }) + | RawClassFieldInitialization::Uninitialized + if magically_initialized => + { + ClassFieldInitialization::Magic + } + RawClassFieldInitialization::Method(MethodThatSetsAttr { + instance_or_class: MethodSelfKind::Instance, + .. + }) => ClassFieldInitialization::Method, + RawClassFieldInitialization::Uninitialized => ClassFieldInitialization::Uninitialized, + }; if let Some(annotation) = direct_annotation.as_ref() { self.validate_direct_annotation( @@ -1607,33 +1656,6 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { } } - /// Extract dataclass field keywords from a call expression if it's a dataclass field specifier. - fn compute_dataclass_field_initialization( - &self, - call: &ExprCall, - dm: &crate::alt::types::class_metadata::DataclassMetadata, - ) -> Option { - let ExprCall { - node_index: _, - range: _, - func, - arguments, - } = call; - // We already type-checked this expression as part of computing the type for the ClassField, - // so we can ignore any errors encountered here. - let ignore_errors = self.error_swallower(); - let func_ty = self.expr_infer(func, &ignore_errors); - let func_kind = func_ty.callee_kind(); - if let Some(func_kind) = func_kind - && dm.field_specifiers.contains(&func_kind) - { - let flags = self.dataclass_field_keywords(&func_ty, arguments, dm, &ignore_errors); - Some(flags) - } else { - None - } - } - fn validate_direct_annotation( &self, annotation: &Annotation, From 10ac53c7dcd6e527a6a713717b8e5680f12c72d9 Mon Sep 17 00:00:00 2001 From: Danny Yang Date: Sun, 23 Nov 2025 15:22:36 -0800 Subject: [PATCH 52/62] preserve quantified information when unpacking quantified type var tuple Summary: Unpacking a `tuple[*Ts]` hits some fallback code and turns the contents into `object` We fix this by introducing an `ElementOf[Ts]` type, which is similar to `Type::QuantifiedValue` for TypeVar but it represents an element of the TypeVarTuple, instead of the whole thing. So when we try to iterate some `tuple[*Ts]`, we produce an unspecified number of `ElementOf[Ts]`, and when we try to make an unbounded tuple with `ElementOf[Ts]` contents we actually create `tuple[*Ts]`. There are additional guards against unpacking the same type var tuple twice in a tuple expression or function call, since the lengths will not match up unless the typevartuple resolves to empty tuple. fixes https://github.com/facebook/pyrefly/issues/1268 Reviewed By: stroxler Differential Revision: D87694394 fbshipit-source-id: 19be3f7282d6e4dfec13e0df5523e2ade658e63a --- conformance/third_party/conformance.exp | 14 +++++++++++++- conformance/third_party/conformance.result | 4 +++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/conformance/third_party/conformance.exp b/conformance/third_party/conformance.exp index 8f03a114c4..0d6b2ecf8a 100644 --- a/conformance/third_party/conformance.exp +++ b/conformance/third_party/conformance.exp @@ -6828,7 +6828,19 @@ "stop_line": 26 } ], - "generics_typevartuple_concat.py": [], + "generics_typevartuple_concat.py": [ + { + "code": -2, + "column": 12, + "concise_description": "Returned type `tuple[*tuple[T | ElementOf[Ts], ...], T | ElementOf[Ts]]` is not assignable to declared return type `tuple[*Ts, T]`", + "description": "Returned type `tuple[*tuple[T | ElementOf[Ts], ...], T | ElementOf[Ts]]` is not assignable to declared return type `tuple[*Ts, T]`", + "line": 56, + "name": "bad-return", + "severity": "error", + "stop_column": 30, + "stop_line": 56 + } + ], "generics_typevartuple_overloads.py": [], "generics_typevartuple_specialization.py": [ { diff --git a/conformance/third_party/conformance.result b/conformance/third_party/conformance.result index 24dead98f6..07e0bf739e 100644 --- a/conformance/third_party/conformance.result +++ b/conformance/third_party/conformance.result @@ -188,7 +188,9 @@ "Line 85: Unexpected errors ['Argument `tuple[float]` is not assignable to parameter `arg2` with type `tuple[int]` in function `func2`']" ], "generics_typevartuple_callable.py": [], - "generics_typevartuple_concat.py": [], + "generics_typevartuple_concat.py": [ + "Line 56: Unexpected errors ['Returned type `tuple[*tuple[T | ElementOf[Ts], ...], T | ElementOf[Ts]]` is not assignable to declared return type `tuple[*Ts, T]`']" + ], "generics_typevartuple_overloads.py": [], "generics_typevartuple_specialization.py": [ "Line 51: Unexpected errors ['assert_type(tuple[int, *tuple[Any, ...]], tuple[int]) failed']", From 13bb911638abbab83aae483ea00e1bf8def73d56 Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 06:27:43 -0800 Subject: [PATCH 53/62] Add OutputWithLocations struct. Summary: This stack introduces a new struct called OutputWithLocations that will be used along with fmt_helper_generic in order to make certain parts of inlay hints clickable. The idea here is that now instead of writing parts of the type directly to a formatter, there is a generic TypeOutput that will be taken in can handle the collection of parts itself. In this case, we will be using an OutputWithLocations in order to collect the parts of the type and also get the location of the types definition if it is relevant. The rest of this stack will handle the actual implementation of this logic. There are a few things that currently are known limitations that will be added as follow ups: 1. Currently tuple types will not be clickable. For example if you have `tuple[int]`, the `int` portion of the type will be clickable but the `tuple` portion will not be. A test has been added that covers this case. 2. Types coming from typing.py will not be clickable. For example, the type `Literal[1]` will not be clickable. This is something that will be addressed in a followup. A test has been added which covers this case. Introduces a new OutputWithLocations struct that implements TypeOutput to capture formatted type strings along with their source code locations. This struct collects type parts as a vector of (String, Option) tuples, enabling clickable type references in IDE features. Also exposes fmt_helper_generic as public to support the new location-aware formatting infrastructure. Adds the OutputWithLocations struct that implements the TypeOutput trait. This struct stores type parts as a vector of (String, Option) tuples, allowing us to capture both the formatted type string and its source location. The struct provides a new() constructor that takes a TypeDisplayContext and a parts() getter to access the collected parts. This is the first step in enabling clickable type references in IDE features. Reviewed By: ndmitchell Differential Revision: D87708533 fbshipit-source-id: 09d9c705974ff373a291d43880ffa752c198825b --- crates/pyrefly_types/src/type_output.rs | 487 ------------------------ 1 file changed, 487 deletions(-) diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index c3ef11fe65..d59d50ec71 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -75,490 +75,3 @@ pub struct OutputWithLocations<'a> { parts: Vec<(String, Option)>, context: &'a TypeDisplayContext<'a>, } - -impl<'a> OutputWithLocations<'a> { - pub fn new(context: &'a TypeDisplayContext<'a>) -> Self { - Self { - parts: Vec::new(), - context, - } - } - - pub fn parts(&self) -> &[(String, Option)] { - &self.parts - } -} - -impl TypeOutput for OutputWithLocations<'_> { - fn write_str(&mut self, s: &str) -> fmt::Result { - self.parts.push((s.to_owned(), None)); - Ok(()) - } - - fn write_qname(&mut self, qname: &QName) -> fmt::Result { - let location = TextRangeWithModule::new(qname.module().clone(), qname.range()); - self.parts.push((qname.id().to_string(), Some(location))); - Ok(()) - } - - fn write_lit(&mut self, lit: &Lit) -> fmt::Result { - // Format the literal and extract location if it's an Enum literal - let formatted = lit.to_string(); - let location = match lit { - Lit::Enum(lit_enum) => { - // Enum literals have a class with a qname that has location info - let qname = lit_enum.class.qname(); - Some(TextRangeWithModule::new( - qname.module().clone(), - qname.range(), - )) - } - _ => None, - }; - self.parts.push((formatted, location)); - Ok(()) - } - - fn write_targs(&mut self, targs: &TArgs) -> fmt::Result { - // Write each type argument separately with its own location - // This ensures that each type in a union (e.g., int | str) gets its own - // clickable part with a link to its definition - if !targs.is_empty() { - self.write_str("[")?; - for (i, ty) in targs.as_slice().iter().enumerate() { - if i > 0 { - self.write_str(", ")?; - } - self.write_type(ty)?; - } - self.write_str("]")?; - } - Ok(()) - } - - fn write_type(&mut self, ty: &Type) -> fmt::Result { - // Format the type and extract location if it has a qname - self.context.fmt_helper_generic(ty, false, self) - } -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - use std::sync::Arc; - - use pyrefly_python::module::Module; - use pyrefly_python::module_name::ModuleName; - use pyrefly_python::module_path::ModulePath; - use pyrefly_python::nesting_context::NestingContext; - use pyrefly_python::qname::QName; - use ruff_python_ast::Identifier; - use ruff_python_ast::name::Name; - use ruff_text_size::TextRange; - use ruff_text_size::TextSize; - - use super::*; - use crate::class::Class; - use crate::class::ClassDefIndex; - use crate::class::ClassType; - use crate::lit_int::LitInt; - use crate::literal::LitEnum; - use crate::quantified::Quantified; - use crate::quantified::QuantifiedKind; - use crate::tuple::Tuple; - use crate::type_var::PreInferenceVariance; - use crate::type_var::Restriction; - use crate::types::TArgs; - use crate::types::TParam; - use crate::types::TParams; - - fn fake_class(name: &str, module: &str, range: u32) -> Class { - let mi = Module::new( - ModuleName::from_str(module), - ModulePath::filesystem(PathBuf::from(module)), - Arc::new("1234567890".to_owned()), - ); - - Class::new( - ClassDefIndex(0), - Identifier::new(Name::new(name), TextRange::empty(TextSize::new(range))), - NestingContext::toplevel(), - mi, - None, - starlark_map::small_map::SmallMap::new(), - ) - } - - #[test] - fn test_output_with_locations_write_str() { - let context = TypeDisplayContext::default(); - let mut output = OutputWithLocations::new(&context); - - assert_eq!(output.parts().len(), 0); - - output.write_str("hello").unwrap(); - assert_eq!(output.parts().len(), 1); - assert_eq!(output.parts()[0].0, "hello"); - assert!(output.parts()[0].1.is_none()); - - output.write_str(" world").unwrap(); - assert_eq!(output.parts().len(), 2); - assert_eq!(output.parts()[1].0, " world"); - assert!(output.parts()[1].1.is_none()); - - let parts = output.parts(); - assert_eq!(parts[0].0, "hello"); - assert_eq!(parts[1].0, " world"); - } - - #[test] - fn test_output_with_locations_write_qname() { - let context = TypeDisplayContext::default(); - let mut output = OutputWithLocations::new(&context); - - let module = Module::new( - ModuleName::from_str("test_module"), - ModulePath::filesystem(PathBuf::from("test_module.py")), - Arc::new("def foo(): pass".to_owned()), - ); - - let identifier = Identifier::new( - Name::new("MyClass"), - TextRange::new(TextSize::new(4), TextSize::new(11)), - ); - - let qname = QName::new(identifier, NestingContext::toplevel(), module.clone()); - output.write_qname(&qname).unwrap(); - - assert_eq!(output.parts().len(), 1); - let (name_str, location) = &output.parts()[0]; - assert_eq!(name_str, "MyClass"); - - assert!(location.is_some()); - let loc = location.as_ref().unwrap(); - assert_eq!( - loc.range, - TextRange::new(TextSize::new(4), TextSize::new(11)) - ); - assert_eq!(loc.module.name(), ModuleName::from_str("test_module")); - } - - #[test] - fn test_output_with_locations_write_lit_non_enum() { - let context = TypeDisplayContext::default(); - let mut output = OutputWithLocations::new(&context); - - // Test with a string literal - should have no location - let str_lit = Lit::Str("hello".into()); - output.write_lit(&str_lit).unwrap(); - - assert_eq!(output.parts().len(), 1); - assert_eq!(output.parts()[0].0, "'hello'"); - assert!(output.parts()[0].1.is_none()); - } - - #[test] - fn test_output_with_locations_write_lit_enum() { - let context = TypeDisplayContext::default(); - let mut output = OutputWithLocations::new(&context); - - // Create an Enum literal with location information - let enum_class = ClassType::new(fake_class("Color", "colors", 10), TArgs::default()); - - let enum_lit = Lit::Enum(Box::new(LitEnum { - class: enum_class, - member: Name::new("RED"), - ty: Type::any_implicit(), - })); - - output.write_lit(&enum_lit).unwrap(); - - assert_eq!(output.parts().len(), 1); - let (formatted, location) = &output.parts()[0]; - - assert_eq!(formatted, "Color.RED"); - - // Verify the location was captured from the enum's class qname - assert!(location.is_some()); - let loc = location.as_ref().unwrap(); - assert_eq!(loc.range, TextRange::empty(TextSize::new(10))); - assert_eq!(loc.module.name(), ModuleName::from_str("colors")); - } - - #[test] - fn test_output_with_locations_write_targs_multiple() { - let context = TypeDisplayContext::default(); - let mut output = OutputWithLocations::new(&context); - - // Create TArgs with multiple type arguments - let tparam1 = TParam { - quantified: Quantified::new( - pyrefly_util::uniques::UniqueFactory::new().fresh(), - Name::new("T"), - QuantifiedKind::TypeVar, - None, - Restriction::Unrestricted, - ), - variance: PreInferenceVariance::PInvariant, - }; - let tparam2 = TParam { - quantified: Quantified::new( - pyrefly_util::uniques::UniqueFactory::new().fresh(), - Name::new("U"), - QuantifiedKind::TypeVar, - None, - Restriction::Unrestricted, - ), - variance: PreInferenceVariance::PInvariant, - }; - let tparam3 = TParam { - quantified: Quantified::new( - pyrefly_util::uniques::UniqueFactory::new().fresh(), - Name::new("V"), - QuantifiedKind::TypeVar, - None, - Restriction::Unrestricted, - ), - variance: PreInferenceVariance::PInvariant, - }; - - let tparams = Arc::new(TParams::new(vec![tparam1, tparam2, tparam3])); - let targs = TArgs::new( - tparams, - vec![Type::None, Type::LiteralString, Type::any_explicit()], - ); - - output.write_targs(&targs).unwrap(); - - // Now that write_type is implemented, it actually writes the types - // Should have: "[", "None", ", ", "LiteralString", ", ", "Any", "]" - assert_eq!(output.parts().len(), 7); - assert_eq!(output.parts()[0].0, "["); - assert!(output.parts()[0].1.is_none()); - - assert_eq!(output.parts()[1].0, "None"); - assert!(output.parts()[1].1.is_none()); - - assert_eq!(output.parts()[2].0, ", "); - assert!(output.parts()[2].1.is_none()); - - assert_eq!(output.parts()[3].0, "LiteralString"); - assert!(output.parts()[3].1.is_none()); - - assert_eq!(output.parts()[4].0, ", "); - assert!(output.parts()[4].1.is_none()); - - assert_eq!(output.parts()[5].0, "Any"); - assert!(output.parts()[5].1.is_none()); - - assert_eq!(output.parts()[6].0, "]"); - assert!(output.parts()[6].1.is_none()); - } - - #[test] - fn test_output_with_locations_write_type_simple() { - let context = TypeDisplayContext::default(); - let mut output = OutputWithLocations::new(&context); - - // Test simple types that don't have locations - output.write_type(&Type::None).unwrap(); - assert_eq!(output.parts().len(), 1); - assert_eq!(output.parts()[0].0, "None"); - assert!(output.parts()[0].1.is_none()); - - output.write_type(&Type::LiteralString).unwrap(); - assert_eq!(output.parts().len(), 2); - assert_eq!(output.parts()[1].0, "LiteralString"); - assert!(output.parts()[1].1.is_none()); - } - - #[test] - fn test_output_with_locations_write_type_tuple() { - // Test tuple[int, str] - let int_class = fake_class("int", "builtins", 30); - let str_class = fake_class("str", "builtins", 40); - - let int_type = Type::ClassType(ClassType::new(int_class, TArgs::default())); - let str_type = Type::ClassType(ClassType::new(str_class, TArgs::default())); - let tuple_type = Type::Tuple(Tuple::Concrete(vec![int_type.clone(), str_type.clone()])); - - let context = TypeDisplayContext::new(&[&tuple_type, &int_type, &str_type]); - let mut output = OutputWithLocations::new(&context); - - output.write_type(&tuple_type).unwrap(); - assert!(!output.parts().is_empty()); - - // Find the int and str parts and verify they have locations - let int_part = output.parts().iter().find(|p| p.0 == "int"); - assert!(int_part.is_some()); - assert!(int_part.unwrap().1.is_some()); - - let str_part = output.parts().iter().find(|p| p.0 == "str"); - assert!(str_part.is_some()); - assert!(str_part.unwrap().1.is_some()); - } - - #[test] - fn test_output_with_locations_union_type_splits_properly() { - // Create int | str | None type - let int_class = fake_class("int", "builtins", 10); - let str_class = fake_class("str", "builtins", 20); - - let int_type = Type::ClassType(ClassType::new(int_class, TArgs::default())); - let str_type = Type::ClassType(ClassType::new(str_class, TArgs::default())); - let union_type = Type::union(vec![int_type, str_type, Type::None]); - - let ctx = TypeDisplayContext::new(&[&union_type]); - let mut output = OutputWithLocations::new(&ctx); - - ctx.fmt_helper_generic(&union_type, false, &mut output) - .unwrap(); - - let parts_str: String = output.parts().iter().map(|(s, _)| s.as_str()).collect(); - assert_eq!(parts_str, "int | str | None"); - - // New behavior: Union types are split into separate parts - // Expected: [("int", Some(location)), (" | ", None), ("str", Some(location)), (" | ", None), ("None", None)] - let parts = output.parts(); - assert_eq!(parts.len(), 5, "Union should be split into 5 parts"); - - // Verify each part - assert_eq!(parts[0].0, "int"); - assert!(parts[0].1.is_some(), "int should have location"); - - assert_eq!(parts[1].0, " | "); - assert!(parts[1].1.is_none(), "separator should not have location"); - - assert_eq!(parts[2].0, "str"); - assert!(parts[2].1.is_some(), "str should have location"); - - assert_eq!(parts[3].0, " | "); - assert!(parts[3].1.is_none(), "separator should not have location"); - - assert_eq!(parts[4].0, "None"); - assert!(parts[4].1.is_none(), "None should not have location"); - } - - #[test] - fn test_output_with_locations_intersection_type_splits_properly() { - // Create int & str type (doesn't make sense semantically, but tests the formatting) - let int_type = Type::ClassType(ClassType::new( - fake_class("int", "builtins", 10), - TArgs::default(), - )); - let str_type = Type::ClassType(ClassType::new( - fake_class("str", "builtins", 20), - TArgs::default(), - )); - let intersect_type = - Type::Intersect(Box::new((vec![int_type, str_type], Type::any_implicit()))); - - let ctx = TypeDisplayContext::new(&[&intersect_type]); - let mut output = OutputWithLocations::new(&ctx); - - // Format the type using fmt_helper_generic - ctx.fmt_helper_generic(&intersect_type, false, &mut output) - .unwrap(); - - // Check the concatenated result - let parts_str: String = output.parts().iter().map(|(s, _)| s.as_str()).collect(); - assert_eq!(parts_str, "int & str"); - - // New behavior: Intersection types are split into separate parts - // Expected: [("int", Some(location)), (" & ", None), ("str", Some(location))] - let parts = output.parts(); - assert_eq!(parts.len(), 3, "Intersection should be split into 3 parts"); - - // Verify each part - assert_eq!(parts[0].0, "int"); - assert!(parts[0].1.is_some(), "int should have location"); - - assert_eq!(parts[1].0, " & "); - assert!(parts[1].1.is_none(), "separator should not have location"); - - assert_eq!(parts[2].0, "str"); - assert!(parts[2].1.is_some(), "str should have location"); - } - - #[test] - fn test_output_with_locations_tuple_base_not_clickable() { - // TODO(jvansch): When implementing clickable support for the base type in generics like tuple[int], - // update this test to verify that "tuple" has a location and is clickable. - // Expected future behavior: [("tuple", Some(location)), ("[", None), ("int", Some(location)), ("]", None)] - - // Create tuple[int] type - let int_class = fake_class("int", "builtins", 10); - let int_type = Type::ClassType(ClassType::new(int_class, TArgs::default())); - let tuple_type = Type::Tuple(Tuple::Concrete(vec![int_type])); - - let ctx = TypeDisplayContext::new(&[&tuple_type]); - let mut output = OutputWithLocations::new(&ctx); - - ctx.fmt_helper_generic(&tuple_type, false, &mut output) - .unwrap(); - - let parts_str: String = output.parts().iter().map(|(s, _)| s.as_str()).collect(); - assert_eq!(parts_str, "tuple[int]"); - - // Current behavior: The "tuple" part is NOT clickable - // Expected parts: [("tuple", None), ("[", None), ("int", Some(location)), ("]", None)] - let parts = output.parts(); - assert_eq!(parts.len(), 4, "Should have 4 parts"); - - // Verify each part - assert_eq!(parts[0].0, "tuple"); - assert!( - parts[0].1.is_none(), - "tuple[ should not have location (not clickable)" - ); - - assert_eq!(parts[1].0, "["); - assert!(parts[1].1.is_none(), "[ should not have location"); - - assert_eq!(parts[2].0, "int"); - assert!(parts[2].1.is_some(), "int should have location (clickable)"); - - assert_eq!(parts[3].0, "]"); - assert!(parts[3].1.is_none(), "] should not have location"); - } - - #[test] - fn test_output_with_locations_literal_base_not_clickable() { - // TODO(jvansch): When implementing clickable support for the base type in special forms like Literal[1], - // update this test to verify that "Literal" has a location and is clickable. - // Expected future behavior: [("Literal", Some(location)), ("[", None), ("1", None), ("]", None)] - - // Create Literal[1] type - let literal_type = Type::Literal(Lit::Int(LitInt::new(1))); - - let ctx = TypeDisplayContext::new(&[&literal_type]); - let mut output = OutputWithLocations::new(&ctx); - - ctx.fmt_helper_generic(&literal_type, false, &mut output) - .unwrap(); - - let parts_str: String = output.parts().iter().map(|(s, _)| s.as_str()).collect(); - assert_eq!(parts_str, "Literal[1]"); - - // Current behavior: The "Literal" part is NOT clickable - // Expected parts: [("Literal", None), ("[", None), ("1", None), ("]", None)] - let parts = output.parts(); - assert_eq!(parts.len(), 4, "Should have 4 parts"); - - // Verify each part - assert_eq!(parts[0].0, "Literal"); - assert!( - parts[0].1.is_none(), - "Literal should not have location (not clickable)" - ); - - assert_eq!(parts[1].0, "["); - assert!(parts[1].1.is_none(), "[ should not have location"); - - assert_eq!(parts[2].0, "1"); - assert!(parts[2].1.is_none(), "1 should not have location"); - - assert_eq!(parts[3].0, "]"); - assert!(parts[3].1.is_none(), "] should not have location"); - } -} From 6e7d7cb44325b9a6c1ff7728f3fc34ca025b4919 Mon Sep 17 00:00:00 2001 From: generatedunixname89002005307016 Date: Mon, 24 Nov 2025 07:06:33 -0800 Subject: [PATCH 54/62] Update pyrefly version] Reviewed By: javabster Differential Revision: D87754773 fbshipit-source-id: 8c0f58e5b9e40be3916a4c74137a9ab4faf37415 --- Cargo.lock | 18 +++++++++--------- crates/pyrefly_build/Cargo.toml | 2 +- crates/pyrefly_bundled/Cargo.toml | 2 +- crates/pyrefly_config/Cargo.toml | 2 +- crates/pyrefly_derive/Cargo.toml | 2 +- crates/pyrefly_python/Cargo.toml | 2 +- crates/pyrefly_types/Cargo.toml | 2 +- crates/pyrefly_util/Cargo.toml | 2 +- crates/tsp_types/Cargo.toml | 2 +- pyrefly/Cargo.toml | 2 +- version.bzl | 2 +- 11 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 614952e21b..80e441eea4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2016,7 +2016,7 @@ dependencies = [ [[package]] name = "pyrefly" -version = "0.43.1" +version = "0.43.0" dependencies = [ "anstream", "anyhow", @@ -2069,7 +2069,7 @@ dependencies = [ [[package]] name = "pyrefly_build" -version = "0.43.1" +version = "0.43.0" dependencies = [ "anyhow", "dupe", @@ -2088,7 +2088,7 @@ dependencies = [ [[package]] name = "pyrefly_bundled" -version = "0.43.1" +version = "0.43.0" dependencies = [ "anyhow", "sha2", @@ -2099,7 +2099,7 @@ dependencies = [ [[package]] name = "pyrefly_config" -version = "0.43.1" +version = "0.43.0" dependencies = [ "anyhow", "clap", @@ -2134,7 +2134,7 @@ dependencies = [ [[package]] name = "pyrefly_derive" -version = "0.43.1" +version = "0.43.0" dependencies = [ "proc-macro2", "quote", @@ -2143,7 +2143,7 @@ dependencies = [ [[package]] name = "pyrefly_python" -version = "0.43.1" +version = "0.43.0" dependencies = [ "anyhow", "clap", @@ -2170,7 +2170,7 @@ dependencies = [ [[package]] name = "pyrefly_types" -version = "0.43.1" +version = "0.43.0" dependencies = [ "compact_str 0.8.0", "dupe", @@ -2190,7 +2190,7 @@ dependencies = [ [[package]] name = "pyrefly_util" -version = "0.43.1" +version = "0.43.0" dependencies = [ "anstream", "anyhow", @@ -3350,7 +3350,7 @@ dependencies = [ [[package]] name = "tsp_types" -version = "0.43.1" +version = "0.43.0" dependencies = [ "lsp-server", "lsp-types", diff --git a/crates/pyrefly_build/Cargo.toml b/crates/pyrefly_build/Cargo.toml index 23bd4c5d9b..05e92b9493 100644 --- a/crates/pyrefly_build/Cargo.toml +++ b/crates/pyrefly_build/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_build" -version = "0.43.1" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_bundled/Cargo.toml b/crates/pyrefly_bundled/Cargo.toml index 2a67c4c5a2..3750f472cb 100644 --- a/crates/pyrefly_bundled/Cargo.toml +++ b/crates/pyrefly_bundled/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_bundled" -version = "0.43.1" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_config/Cargo.toml b/crates/pyrefly_config/Cargo.toml index b116e6ef01..94957fd590 100644 --- a/crates/pyrefly_config/Cargo.toml +++ b/crates/pyrefly_config/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_config" -version = "0.43.1" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_derive/Cargo.toml b/crates/pyrefly_derive/Cargo.toml index 749f5e7919..ef7b5abc6b 100644 --- a/crates/pyrefly_derive/Cargo.toml +++ b/crates/pyrefly_derive/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_derive" -version = "0.43.1" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_python/Cargo.toml b/crates/pyrefly_python/Cargo.toml index f2891e39c9..4ef26d07e2 100644 --- a/crates/pyrefly_python/Cargo.toml +++ b/crates/pyrefly_python/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_python" -version = "0.43.1" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_types/Cargo.toml b/crates/pyrefly_types/Cargo.toml index 94c577f810..31ab233201 100644 --- a/crates/pyrefly_types/Cargo.toml +++ b/crates/pyrefly_types/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_types" -version = "0.43.1" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/pyrefly_util/Cargo.toml b/crates/pyrefly_util/Cargo.toml index 8e11157a66..6e3943ed2f 100644 --- a/crates/pyrefly_util/Cargo.toml +++ b/crates/pyrefly_util/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly_util" -version = "0.43.1" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/crates/tsp_types/Cargo.toml b/crates/tsp_types/Cargo.toml index 90989f622c..8062fd84de 100644 --- a/crates/tsp_types/Cargo.toml +++ b/crates/tsp_types/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "tsp_types" -version = "0.43.1" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/pyrefly/Cargo.toml b/pyrefly/Cargo.toml index 86a69eb094..89da866ceb 100644 --- a/pyrefly/Cargo.toml +++ b/pyrefly/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "pyrefly" -version = "0.43.1" +version = "0.43.0" authors = ["Meta"] edition = "2024" repository = "https://github.com/facebook/pyrefly" diff --git a/version.bzl b/version.bzl index 8fcb248ecd..746d759eda 100644 --- a/version.bzl +++ b/version.bzl @@ -13,4 +13,4 @@ # * After updating the version, run `arc autocargo -p pyrefly` to regenerate `Cargo.toml` # and put the resulting diff up for review. Once the diff lands, the new version should be # available on PyPI within a few hours. -VERSION = "0.43.1" +VERSION = "0.43.0" From 63f86cb2498fdb9c7120b27bd626923375fe384c Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 24 Nov 2025 09:13:06 -0800 Subject: [PATCH 55/62] fix parsing of multi-line parameter descriptions in docstrings #1588 (#1629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: fix https://github.com/facebook/pyrefly/issues/1588 Added a docstring parsing pipeline that normalizes raw literals, extracts `multi-line :param …:` and `Args: sections`, and exposes the results via parse_parameter_documentation, plus unit tests covering both styles. This provides the richer parameter metadata requested in the issue. An extended signature helps to capture the callee range, look up the associated docstring, and parse parameter descriptions and surface them through ParameterInformation documentation, so hover/signature hints show the full multi-line text Added an integration test to ensure signature helps surface the parsed docs and kept the helper coverage in sync Pull Request resolved: https://github.com/facebook/pyrefly/pull/1629 Reviewed By: yangdanny97 Differential Revision: D87673056 Pulled By: kinto0 fbshipit-source-id: 6bea24261d9c27647b629272995d76ecdd176cf8 --- crates/pyrefly_python/src/docstring.rs | 233 ------------------------- pyrefly/lib/state/lsp.rs | 25 +++ pyrefly/lib/test/lsp/hover.rs | 105 ----------- 3 files changed, 25 insertions(+), 338 deletions(-) diff --git a/crates/pyrefly_python/src/docstring.rs b/crates/pyrefly_python/src/docstring.rs index d5ae77d227..0ff757ae72 100644 --- a/crates/pyrefly_python/src/docstring.rs +++ b/crates/pyrefly_python/src/docstring.rs @@ -450,237 +450,4 @@ Args: assert_eq!(docs.get("foo").unwrap(), "first line\nsecond line"); assert_eq!(docs.get("bar").unwrap(), "final"); } - - #[test] - fn test_parse_sphinx_empty_param() { - let doc = r#" -:param foo: -:param bar: has description -"#; - let docs = parse_parameter_documentation(doc); - assert_eq!(docs.get("foo"), None); // Empty params should not be included - assert_eq!(docs.get("bar").unwrap(), "has description"); - } - - #[test] - fn test_parse_sphinx_with_type_annotations() { - let doc = r#" -:param int foo: an integer -:param str bar: a string -:param Optional[Dict[str, int]] baz: complex type -"#; - let docs = parse_parameter_documentation(doc); - assert_eq!(docs.get("foo").unwrap(), "an integer"); - assert_eq!(docs.get("bar").unwrap(), "a string"); - assert_eq!(docs.get("baz").unwrap(), "complex type"); - } - - #[test] - fn test_parse_sphinx_multiple_continuation_lines() { - let doc = r#" -:param foo: line one - line two - line three - line four -:param bar: single line -"#; - let docs = parse_parameter_documentation(doc); - assert_eq!( - docs.get("foo").unwrap(), - "line one\nline two\nline three\nline four" - ); - assert_eq!(docs.get("bar").unwrap(), "single line"); - } - - #[test] - fn test_parse_sphinx_with_other_directives() { - let doc = r#" -:param foo: the foo parameter -:param bar: the bar parameter -:return: the return value -:raises ValueError: when invalid -:type foo: int -"#; - let docs = parse_parameter_documentation(doc); - assert_eq!(docs.get("foo").unwrap(), "the foo parameter"); - assert_eq!(docs.get("bar").unwrap(), "the bar parameter"); - // Other directives should not be included as parameters - assert_eq!(docs.get("return"), None); - assert_eq!(docs.get("raises"), None); - } - - #[test] - fn test_parse_sphinx_with_varargs() { - let doc = r#" -:param *args: positional arguments -:param **kwargs: keyword arguments -:param regular: regular param -"#; - let docs = parse_parameter_documentation(doc); - assert_eq!(docs.get("args").unwrap(), "positional arguments"); - assert_eq!(docs.get("kwargs").unwrap(), "keyword arguments"); - assert_eq!(docs.get("regular").unwrap(), "regular param"); - } - - #[test] - fn test_parse_sphinx_indented_in_docstring() { - let doc = r#""" - Summary of function. - - :param foo: first param - with continuation - :param bar: second param - """#; - let docs = parse_parameter_documentation(doc); - assert_eq!(docs.get("foo").unwrap(), "first param\nwith continuation"); - assert_eq!(docs.get("bar").unwrap(), "second param"); - } - - #[test] - fn test_parse_google_different_headers() { - let doc = r#" -Arguments: - foo: using Arguments header - bar: second arg - -def another_func(): - """ - Keyword Arguments: - baz: keyword arg - """ -"#; - let docs = parse_parameter_documentation(doc); - assert_eq!(docs.get("foo").unwrap(), "using Arguments header"); - assert_eq!(docs.get("bar").unwrap(), "second arg"); - } - - #[test] - fn test_parse_google_parameters_header() { - let doc = r#" -Parameters: - foo (int): first param - bar (str): second param -"#; - let docs = parse_parameter_documentation(doc); - assert_eq!(docs.get("foo").unwrap(), "first param"); - assert_eq!(docs.get("bar").unwrap(), "second param"); - } - - #[test] - fn test_parse_google_keyword_args_header() { - let doc = r#" -Keyword Args: - foo: keyword arg one - bar: keyword arg two -"#; - let docs = parse_parameter_documentation(doc); - assert_eq!(docs.get("foo").unwrap(), "keyword arg one"); - assert_eq!(docs.get("bar").unwrap(), "keyword arg two"); - } - - #[test] - fn test_parse_google_deeply_indented_continuation() { - let doc = r#" -Args: - foo: first line - second line - third line deeply indented - fourth line - bar: simple -"#; - let docs = parse_parameter_documentation(doc); - assert_eq!( - docs.get("foo").unwrap(), - "first line\nsecond line\nthird line deeply indented\nfourth line" - ); - assert_eq!(docs.get("bar").unwrap(), "simple"); - } - - #[test] - fn test_parse_google_no_type_annotation() { - let doc = r#" -Args: - foo: no type annotation - bar (int): with type annotation - baz: also no type -"#; - let docs = parse_parameter_documentation(doc); - assert_eq!(docs.get("foo").unwrap(), "no type annotation"); - assert_eq!(docs.get("bar").unwrap(), "with type annotation"); - assert_eq!(docs.get("baz").unwrap(), "also no type"); - } - - #[test] - fn test_parse_google_complex_type_annotations() { - let doc = r#" -Args: - foo (Optional[List[Dict[str, int]]]): complex type - bar (Callable[[int, str], bool]): callable type -"#; - let docs = parse_parameter_documentation(doc); - assert_eq!(docs.get("foo").unwrap(), "complex type"); - assert_eq!(docs.get("bar").unwrap(), "callable type"); - } - - #[test] - fn test_parse_google_section_ends_with_other_section() { - let doc = r#" -Args: - foo: the foo param - bar: the bar param - -Returns: - int: the return value - -Raises: - ValueError: when things go wrong -"#; - let docs = parse_parameter_documentation(doc); - assert_eq!(docs.get("foo").unwrap(), "the foo param"); - assert_eq!(docs.get("bar").unwrap(), "the bar param"); - // Returns and Raises should not be parsed as params - assert_eq!(docs.len(), 2); - } - - #[test] - fn test_parse_google_empty_parameter_description() { - let doc = r#" -Args: - foo: - bar: has description -"#; - let docs = parse_parameter_documentation(doc); - assert_eq!(docs.get("foo"), None); // Empty description should not be included - assert_eq!(docs.get("bar").unwrap(), "has description"); - } - - #[test] - fn test_parse_mixed_sphinx_and_google() { - let doc = r#" -:param sphinx_param: using Sphinx style - with continuation - -Args: - google_param: using Google style - another_google: second Google param -"#; - let docs = parse_parameter_documentation(doc); - assert_eq!( - docs.get("sphinx_param").unwrap(), - "using Sphinx style\nwith continuation" - ); - assert_eq!(docs.get("google_param").unwrap(), "using Google style"); - assert_eq!(docs.get("another_google").unwrap(), "second Google param"); - } - - #[test] - fn test_parse_sphinx_param_with_comma_in_type() { - let doc = r#" -:param Dict[str, int] foo: dict param -:param Tuple[int, str, bool] bar: tuple param -"#; - let docs = parse_parameter_documentation(doc); - assert_eq!(docs.get("foo").unwrap(), "dict param"); - assert_eq!(docs.get("bar").unwrap(), "tuple param"); - } } diff --git a/pyrefly/lib/state/lsp.rs b/pyrefly/lib/state/lsp.rs index 120daef16c..abee839ec2 100644 --- a/pyrefly/lib/state/lsp.rs +++ b/pyrefly/lib/state/lsp.rs @@ -2415,6 +2415,31 @@ fn add_literal_completions( position: TextSize, completions: &mut Vec, ) { + let mut in_string = false; + if let Some(module_info) = self.get_module_info(handle) { + let source = module_info.contents(); + match parse_module(source) { + Ok(parsed) => { + for token in parsed.tokens() { + let range = token.range(); + if range.contains(position) || (range.end() == position && token.kind() == TokenKind::String) { + if token.kind() == TokenKind::String { + in_string = true; + } + break; + } + } + } + Err(e) => { + if let ParseErrorType::Lexical(LexicalErrorType::UnclosedStringError) = e.error { + if e.location.start() < position { + in_string = true; + } + } + } + } + } + if let Some((callables, chosen_overload_index, active_argument, _)) = self.get_callables_from_call(handle, position) && let Some(callable) = callables.get(chosen_overload_index) diff --git a/pyrefly/lib/test/lsp/hover.rs b/pyrefly/lib/test/lsp/hover.rs index eba61ad429..b80a16f8b9 100644 --- a/pyrefly/lib/test/lsp/hover.rs +++ b/pyrefly/lib/test/lsp/hover.rs @@ -230,111 +230,6 @@ foo(x=1, y=2) assert!(report.contains("documentation for y")); } -#[test] -fn hover_returns_none_for_docstring_literals() { - let code = r#" -def foo(): - """Function docstring.""" -# ^ - return 1 -"#; - let report = get_batched_lsp_operations_report(&[("main", code)], get_test_report); - assert_eq!( - r#" -# main.py -3 | """Function docstring.""" - ^ -None -"# - .trim(), - report.trim(), - ); -} - -#[test] -fn hover_shows_parameter_doc_with_multiline_description() { - let code = r#" -def foo(param: int) -> None: - """ - Args: - param: This is a long parameter description - that spans multiple lines - with detailed information - """ - ... - -foo(param=1) -# ^ -"#; - let report = get_batched_lsp_operations_report(&[("main", code)], get_test_report); - assert!(report.contains("**Parameter `param`**")); - assert!(report.contains("This is a long parameter description")); - assert!(report.contains("that spans multiple lines")); - assert!(report.contains("with detailed information")); -} - -#[test] -fn hover_on_parameter_definition_shows_doc() { - let code = r#" -def foo(param: int) -> None: - """ - Args: - param: documentation for param - """ - print(param) -# ^ -"#; - let report = get_batched_lsp_operations_report(&[("main", code)], get_test_report); - assert!( - report.contains("**Parameter `param`**"), - "Expected parameter doc when hovering on parameter usage, got: {report}" - ); - assert!(report.contains("documentation for param")); -} - -#[test] -fn hover_parameter_doc_with_type_annotations_in_docstring() { - let code = r#" -def foo(x, y): - """ - Args: - x (int): an integer parameter - y (str): a string parameter - """ - ... - -foo(x=1, y="hello") -# ^ -foo(x=1, y="hello") -# ^ -"#; - let report = get_batched_lsp_operations_report(&[("main", code)], get_test_report); - assert!(report.contains("**Parameter `x`**")); - assert!(report.contains("an integer parameter")); - assert!(report.contains("**Parameter `y`**")); - assert!(report.contains("a string parameter")); -} - -#[test] -fn hover_parameter_doc_with_complex_types() { - let code = r#" -from typing import Optional, List, Dict - -def foo(data: Optional[List[Dict[str, int]]]) -> None: - """ - Args: - data: complex nested type parameter - """ - ... - -foo(data=[]) -# ^ -"#; - let report = get_batched_lsp_operations_report(&[("main", code)], get_test_report); - assert!(report.contains("**Parameter `data`**")); - assert!(report.contains("complex nested type parameter")); -} - #[test] fn hover_over_overloaded_binary_operator_shows_dunder_name() { let code = r#" From aa455f550bde8eeeb4b6dedc8298e23993c91c0c Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 09:29:59 -0800 Subject: [PATCH 56/62] Implement write_type. Summary: This diff introduces the write_type function which is responsible for writing types fall outside of str, qname, targs, etc. This will defer the type back to fmt_helper_generic and use the existing match statement to figure out how to handle this type. Reviewed By: kinto0 Differential Revision: D87642081 fbshipit-source-id: a9846af0aad7b9af7dfd9ffe57fcf0dd811a674a --- crates/pyrefly_types/src/display.rs | 53 +--- crates/pyrefly_types/src/type_output.rs | 322 ++++++++++++++++++++++++ 2 files changed, 323 insertions(+), 52 deletions(-) diff --git a/crates/pyrefly_types/src/display.rs b/crates/pyrefly_types/src/display.rs index 05d7ab2e26..b092d596a8 100644 --- a/crates/pyrefly_types/src/display.rs +++ b/crates/pyrefly_types/src/display.rs @@ -241,61 +241,10 @@ impl<'a> TypeDisplayContext<'a> { output.write_str(&format!("{}.{}", module, name)) } else { output.write_str(name) + // write!(f, "{name}") } } - /// Helper function to format a sequence of types with a separator. - /// Used for unions, intersections, and other type sequences. - fn fmt_type_sequence<'b>( - &self, - types: impl IntoIterator, - separator: &str, - wrap_callables_and_intersect: bool, - output: &mut impl TypeOutput, - ) -> fmt::Result { - for (i, t) in types.into_iter().enumerate() { - if i > 0 { - output.write_str(separator)?; - } - - let needs_parens = wrap_callables_and_intersect - && matches!( - t, - Type::Callable(_) | Type::Function(_) | Type::Intersect(_) - ); - if needs_parens { - output.write_str("(")?; - } - self.fmt_helper_generic(t, false, output)?; - if needs_parens { - output.write_str(")")?; - } - } - Ok(()) - } - - /// Core formatting logic for types that works with any `TypeOutput` implementation. - /// - /// The method uses the `TypeOutput` trait abstraction to write output in various ways. - /// This allows it to work for various purposes. (e.g., `DisplayOutput` for plain text - /// or `OutputWithLocations` for tracking source locations). - /// - /// Note that the formatted type is not actually returned from this function. The type will - /// be collected in whatever `TypeOutput` is provided. - /// - /// # Arguments - /// - /// * `t` - The type to format - /// * `is_toplevel` - Whether this type is at the top level of the display. - /// - When `true` and hover mode is enabled: - /// - Callables, functions, and overloads are formatted with newlines for readability - /// - Functions show `def func_name(...)` syntax instead of compact callable syntax - /// - Overloads are displayed with `@overload` decorators - /// - Type aliases are expanded to show their definition - /// - When `false`, these types use compact inline formatting. - /// * `output` - The output writer implementing `TypeOutput`. This abstraction allows - /// the same formatting logic to be used for different purposes (plain formatting, - /// location tracking, etc.) pub fn fmt_helper_generic( &self, t: &Type, diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index d59d50ec71..05a87655af 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -75,3 +75,325 @@ pub struct OutputWithLocations<'a> { parts: Vec<(String, Option)>, context: &'a TypeDisplayContext<'a>, } + +impl<'a> OutputWithLocations<'a> { + pub fn new(context: &'a TypeDisplayContext<'a>) -> Self { + Self { + parts: Vec::new(), + context, + } + } + + pub fn parts(&self) -> &[(String, Option)] { + &self.parts + } +} + +impl TypeOutput for OutputWithLocations<'_> { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.parts.push((s.to_owned(), None)); + Ok(()) + } + + fn write_qname(&mut self, qname: &QName) -> fmt::Result { + let location = TextRangeWithModule::new(qname.module().clone(), qname.range()); + self.parts.push((qname.id().to_string(), Some(location))); + Ok(()) + } + + fn write_lit(&mut self, lit: &Lit) -> fmt::Result { + // Format the literal and extract location if it's an Enum literal + let formatted = lit.to_string(); + let location = match lit { + Lit::Enum(lit_enum) => { + // Enum literals have a class with a qname that has location info + let qname = lit_enum.class.qname(); + Some(TextRangeWithModule::new( + qname.module().clone(), + qname.range(), + )) + } + _ => None, + }; + self.parts.push((formatted, location)); + Ok(()) + } + + fn write_targs(&mut self, targs: &TArgs) -> fmt::Result { + // Write each type argument separately with its own location + // This ensures that each type in a union (e.g., int | str) gets its own + // clickable part with a link to its definition + if !targs.is_empty() { + self.write_str("[")?; + for (i, ty) in targs.as_slice().iter().enumerate() { + if i > 0 { + self.write_str(", ")?; + } + self.write_type(ty)?; + } + self.write_str("]")?; + } + Ok(()) + } + + fn write_type(&mut self, ty: &Type) -> fmt::Result { + // Format the type and extract location if it has a qname + self.context.fmt_helper_generic(ty, false, self) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use pyrefly_python::module::Module; + use pyrefly_python::module_name::ModuleName; + use pyrefly_python::module_path::ModulePath; + use pyrefly_python::nesting_context::NestingContext; + use pyrefly_python::qname::QName; + use ruff_python_ast::Identifier; + use ruff_python_ast::name::Name; + use ruff_text_size::TextRange; + use ruff_text_size::TextSize; + + use super::*; + use crate::class::Class; + use crate::class::ClassDefIndex; + use crate::class::ClassType; + use crate::literal::LitEnum; + use crate::quantified::Quantified; + use crate::quantified::QuantifiedKind; + use crate::tuple::Tuple; + use crate::type_var::PreInferenceVariance; + use crate::type_var::Restriction; + use crate::types::TArgs; + use crate::types::TParam; + use crate::types::TParams; + + fn fake_class(name: &str, module: &str, range: u32) -> Class { + let mi = Module::new( + ModuleName::from_str(module), + ModulePath::filesystem(PathBuf::from(module)), + Arc::new("1234567890".to_owned()), + ); + + Class::new( + ClassDefIndex(0), + Identifier::new(Name::new(name), TextRange::empty(TextSize::new(range))), + NestingContext::toplevel(), + mi, + None, + starlark_map::small_map::SmallMap::new(), + ) + } + + #[test] + fn test_output_with_locations_write_str() { + let context = TypeDisplayContext::default(); + let mut output = OutputWithLocations::new(&context); + + assert_eq!(output.parts().len(), 0); + + output.write_str("hello").unwrap(); + assert_eq!(output.parts().len(), 1); + assert_eq!(output.parts()[0].0, "hello"); + assert!(output.parts()[0].1.is_none()); + + output.write_str(" world").unwrap(); + assert_eq!(output.parts().len(), 2); + assert_eq!(output.parts()[1].0, " world"); + assert!(output.parts()[1].1.is_none()); + + let parts = output.parts(); + assert_eq!(parts[0].0, "hello"); + assert_eq!(parts[1].0, " world"); + } + + #[test] + fn test_output_with_locations_write_qname() { + let context = TypeDisplayContext::default(); + let mut output = OutputWithLocations::new(&context); + + let module = Module::new( + ModuleName::from_str("test_module"), + ModulePath::filesystem(PathBuf::from("test_module.py")), + Arc::new("def foo(): pass".to_owned()), + ); + + let identifier = Identifier::new( + Name::new("MyClass"), + TextRange::new(TextSize::new(4), TextSize::new(11)), + ); + + let qname = QName::new(identifier, NestingContext::toplevel(), module.clone()); + output.write_qname(&qname).unwrap(); + + assert_eq!(output.parts().len(), 1); + let (name_str, location) = &output.parts()[0]; + assert_eq!(name_str, "MyClass"); + + assert!(location.is_some()); + let loc = location.as_ref().unwrap(); + assert_eq!( + loc.range, + TextRange::new(TextSize::new(4), TextSize::new(11)) + ); + assert_eq!(loc.module.name(), ModuleName::from_str("test_module")); + } + + #[test] + fn test_output_with_locations_write_lit_non_enum() { + let context = TypeDisplayContext::default(); + let mut output = OutputWithLocations::new(&context); + + // Test with a string literal - should have no location + let str_lit = Lit::Str("hello".into()); + output.write_lit(&str_lit).unwrap(); + + assert_eq!(output.parts().len(), 1); + assert_eq!(output.parts()[0].0, "'hello'"); + assert!(output.parts()[0].1.is_none()); + } + + #[test] + fn test_output_with_locations_write_lit_enum() { + let context = TypeDisplayContext::default(); + let mut output = OutputWithLocations::new(&context); + + // Create an Enum literal with location information + let enum_class = ClassType::new(fake_class("Color", "colors", 10), TArgs::default()); + + let enum_lit = Lit::Enum(Box::new(LitEnum { + class: enum_class, + member: Name::new("RED"), + ty: Type::any_implicit(), + })); + + output.write_lit(&enum_lit).unwrap(); + + assert_eq!(output.parts().len(), 1); + let (formatted, location) = &output.parts()[0]; + + assert_eq!(formatted, "Color.RED"); + + // Verify the location was captured from the enum's class qname + assert!(location.is_some()); + let loc = location.as_ref().unwrap(); + assert_eq!(loc.range, TextRange::empty(TextSize::new(10))); + assert_eq!(loc.module.name(), ModuleName::from_str("colors")); + } + + #[test] + fn test_output_with_locations_write_targs_multiple() { + let context = TypeDisplayContext::default(); + let mut output = OutputWithLocations::new(&context); + + // Create TArgs with multiple type arguments + let tparam1 = TParam { + quantified: Quantified::new( + pyrefly_util::uniques::UniqueFactory::new().fresh(), + Name::new("T"), + QuantifiedKind::TypeVar, + None, + Restriction::Unrestricted, + ), + variance: PreInferenceVariance::PInvariant, + }; + let tparam2 = TParam { + quantified: Quantified::new( + pyrefly_util::uniques::UniqueFactory::new().fresh(), + Name::new("U"), + QuantifiedKind::TypeVar, + None, + Restriction::Unrestricted, + ), + variance: PreInferenceVariance::PInvariant, + }; + let tparam3 = TParam { + quantified: Quantified::new( + pyrefly_util::uniques::UniqueFactory::new().fresh(), + Name::new("V"), + QuantifiedKind::TypeVar, + None, + Restriction::Unrestricted, + ), + variance: PreInferenceVariance::PInvariant, + }; + + let tparams = Arc::new(TParams::new(vec![tparam1, tparam2, tparam3])); + let targs = TArgs::new( + tparams, + vec![Type::None, Type::LiteralString, Type::any_explicit()], + ); + + output.write_targs(&targs).unwrap(); + + // Now that write_type is implemented, it actually writes the types + // Should have: "[", "None", ", ", "LiteralString", ", ", "Any", "]" + assert_eq!(output.parts().len(), 7); + assert_eq!(output.parts()[0].0, "["); + assert!(output.parts()[0].1.is_none()); + + assert_eq!(output.parts()[1].0, "None"); + assert!(output.parts()[1].1.is_none()); + + assert_eq!(output.parts()[2].0, ", "); + assert!(output.parts()[2].1.is_none()); + + assert_eq!(output.parts()[3].0, "LiteralString"); + assert!(output.parts()[3].1.is_none()); + + assert_eq!(output.parts()[4].0, ", "); + assert!(output.parts()[4].1.is_none()); + + assert_eq!(output.parts()[5].0, "Any"); + assert!(output.parts()[5].1.is_none()); + + assert_eq!(output.parts()[6].0, "]"); + assert!(output.parts()[6].1.is_none()); + } + + #[test] + fn test_output_with_locations_write_type_simple() { + let context = TypeDisplayContext::default(); + let mut output = OutputWithLocations::new(&context); + + // Test simple types that don't have locations + output.write_type(&Type::None).unwrap(); + assert_eq!(output.parts().len(), 1); + assert_eq!(output.parts()[0].0, "None"); + assert!(output.parts()[0].1.is_none()); + + output.write_type(&Type::LiteralString).unwrap(); + assert_eq!(output.parts().len(), 2); + assert_eq!(output.parts()[1].0, "LiteralString"); + assert!(output.parts()[1].1.is_none()); + } + + #[test] + fn test_output_with_locations_write_type_tuple() { + // Test tuple[int, str] + let int_class = fake_class("int", "builtins", 30); + let str_class = fake_class("str", "builtins", 40); + + let int_type = Type::ClassType(ClassType::new(int_class, TArgs::default())); + let str_type = Type::ClassType(ClassType::new(str_class, TArgs::default())); + let tuple_type = Type::Tuple(Tuple::Concrete(vec![int_type.clone(), str_type.clone()])); + + let context = TypeDisplayContext::new(&[&tuple_type, &int_type, &str_type]); + let mut output = OutputWithLocations::new(&context); + + output.write_type(&tuple_type).unwrap(); + assert!(!output.parts().is_empty()); + + // Find the int and str parts and verify they have locations + let int_part = output.parts().iter().find(|p| p.0 == "int"); + assert!(int_part.is_some()); + assert!(int_part.unwrap().1.is_some()); + + let str_part = output.parts().iter().find(|p| p.0 == "str"); + assert!(str_part.is_some()); + assert!(str_part.unwrap().1.is_some()); + } +} From b26bb7ef2d4d3b099d43c29b6a3951f0361447fd Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Mon, 24 Nov 2025 10:13:51 -0800 Subject: [PATCH 57/62] fix unused variable detection for reassignments Summary: fix https://github.com/facebook/pyrefly/issues/1670 an alternative approach could be to use find-references to find unused variables. but if we ever want this diagnostic on the CLI (I think we do), we will likely want it less coupled to the language server. Reviewed By: stroxler Differential Revision: D87785750 fbshipit-source-id: 8f2abc30693714eb5df7523e36eb70c4f6eacd3e --- pyrefly/lib/test/lsp/diagnostic.rs | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/pyrefly/lib/test/lsp/diagnostic.rs b/pyrefly/lib/test/lsp/diagnostic.rs index c1524d16cf..1bd5248df8 100644 --- a/pyrefly/lib/test/lsp/diagnostic.rs +++ b/pyrefly/lib/test/lsp/diagnostic.rs @@ -135,20 +135,6 @@ def process(items: List[str]): assert_eq!(report, "Import `Dict` is unused"); } -#[test] -fn test_star_import_not_reported_as_unused() { - let code = r#" -from typing import * - -def foo() -> str: - return "hello" -"#; - let (handles, state) = mk_multi_file_state(&[("main", code)], Require::indexing(), true); - let handle = handles.get("main").unwrap(); - let report = get_unused_import_diagnostics(&state, handle); - assert_eq!(report, "No unused imports"); -} - #[test] fn test_generator_with_send() { let code = r#" @@ -165,21 +151,6 @@ def test() -> Generator[float, float, None]: assert_eq!(report, "No unused variables"); } -// TODO: x = 7 should be highlighted as unused -#[test] -fn test_reassignment_false_negative() { - let code = r#" -def f(): - x = 5 - print(x) - x = 7 -"#; - let (handles, state) = mk_multi_file_state(&[("main", code)], Require::indexing(), true); - let handle = handles.get("main").unwrap(); - let report = get_unused_variable_diagnostics(&state, handle); - assert_eq!(report, "No unused variables"); -} - #[test] fn test_loop_with_reassignment() { let code = r#" From ef09f28d5e7d12d3b5a814fc905e272d530dc88f Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Mon, 24 Nov 2025 10:13:51 -0800 Subject: [PATCH 58/62] filter star imports from unused detection Summary: we should never warn on unused star imports Reviewed By: stroxler Differential Revision: D87788170 fbshipit-source-id: 61c3d914b674d309a44f2f03d3df9e6d628b78bd --- pyrefly/lib/binding/stmt.rs | 2 +- pyrefly/lib/test/lsp/diagnostic.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pyrefly/lib/binding/stmt.rs b/pyrefly/lib/binding/stmt.rs index 3fa01f52e5..4c97a6b5c2 100644 --- a/pyrefly/lib/binding/stmt.rs +++ b/pyrefly/lib/binding/stmt.rs @@ -1024,7 +1024,7 @@ impl<'a> BindingsBuilder<'a> { // Register the imported name from wildcard imports self.scopes.register_import_with_star( &Identifier { - node_index: AtomicNodeIndex::default(), + node_index: AtomicNodeIndex::dummy(), id: name.into_key().clone(), range: x.range, }, diff --git a/pyrefly/lib/test/lsp/diagnostic.rs b/pyrefly/lib/test/lsp/diagnostic.rs index 1bd5248df8..390fd93c71 100644 --- a/pyrefly/lib/test/lsp/diagnostic.rs +++ b/pyrefly/lib/test/lsp/diagnostic.rs @@ -135,6 +135,20 @@ def process(items: List[str]): assert_eq!(report, "Import `Dict` is unused"); } +#[test] +fn test_star_import_not_reported_as_unused() { + let code = r#" +from typing import * + +def foo() -> str: + return "hello" +"#; + let (handles, state) = mk_multi_file_state(&[("main", code)], Require::indexing(), true); + let handle = handles.get("main").unwrap(); + let report = get_unused_import_diagnostics(&state, handle); + assert_eq!(report, "No unused imports"); +} + #[test] fn test_generator_with_send() { let code = r#" From ebadfed57c1a27323541d4622cd7502ffb1fcc7e Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 10:46:58 -0800 Subject: [PATCH 59/62] Update Union Logic to properly split into parts. Summary: Refactors union type formatting to output individual type components and separators as distinct parts, enabling location tracking for each type in the union. Literal types within unions are still combined into a single "Literal[a, b, c]" for better readability. The refactored code uses the new fmt_type_sequence helper for non-literal union members and manually formats the combined literal, calling write_str and write_type methods on the TypeOutput trait instead of building intermediate strings. This fixes the test_output_with_locations_union_type_splits_properly test. Reviewed By: kinto0 Differential Revision: D87642077 fbshipit-source-id: c7b6f49973be8c25adcddfd663c6a111c9b35cce --- crates/pyrefly_types/src/display.rs | 71 ++++++++++++++++++--- crates/pyrefly_types/src/type_output.rs | 82 +++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 10 deletions(-) diff --git a/crates/pyrefly_types/src/display.rs b/crates/pyrefly_types/src/display.rs index b092d596a8..0c62503c66 100644 --- a/crates/pyrefly_types/src/display.rs +++ b/crates/pyrefly_types/src/display.rs @@ -245,6 +245,36 @@ impl<'a> TypeDisplayContext<'a> { } } + /// Helper function to format a sequence of types with a separator. + /// Used for unions, intersections, and other type sequences. + fn fmt_type_sequence<'b>( + &self, + types: impl IntoIterator, + separator: &str, + wrap_callables_and_intersect: bool, + output: &mut impl TypeOutput, + ) -> fmt::Result { + for (i, t) in types.into_iter().enumerate() { + if i > 0 { + output.write_str(separator)?; + } + + let needs_parens = wrap_callables_and_intersect + && matches!( + t, + Type::Callable(_) | Type::Function(_) | Type::Intersect(_) + ); + if needs_parens { + output.write_str("(")?; + } + self.fmt_helper_generic(t, false, output)?; + if needs_parens { + output.write_str(")")?; + } + } + Ok(()) + } + pub fn fmt_helper_generic( &self, t: &Type, @@ -417,21 +447,14 @@ impl<'a> TypeDisplayContext<'a> { Type::Union(box Union { members: types, .. }) if types.is_empty() => { self.maybe_fmt_with_module("typing", "Never", output) } - Type::Union(box Union { - display_name: Some(name), - .. - }) => output.write_str(name), - Type::Union(box Union { - members, - display_name: None, - }) => { + Type::Union(types) => { let mut literal_idx = None; let mut literals = Vec::new(); let mut union_members: Vec<&Type> = Vec::new(); // Track seen types to deduplicate (mainly to prettify types for functions with different names but the same signature) let mut seen_types = SmallSet::new(); - for t in members.iter() { + for t in types.iter() { match t { Type::Literal(lit) => { if literal_idx.is_none() { @@ -523,7 +546,35 @@ impl<'a> TypeDisplayContext<'a> { self.fmt_type_sequence(union_members, " | ", true, output) } } - Type::Intersect(x) => self.fmt_type_sequence(x.0.iter(), " & ", true, output), + Type::Intersect(x) => { + let display_types: Vec = + x.0.iter() + .map(|t| { + let mut temp = String::new(); + { + use std::fmt::Write; + match t { + Type::Callable(_) | Type::Function(_) => { + let temp_formatter = Fmt(|f| { + let mut temp_output = DisplayOutput::new(self, f); + self.fmt_helper_generic(t, false, &mut temp_output) + }); + write!(&mut temp, "({})", temp_formatter).ok(); + } + _ => { + let temp_formatter = Fmt(|f| { + let mut temp_output = DisplayOutput::new(self, f); + self.fmt_helper_generic(t, false, &mut temp_output) + }); + write!(&mut temp, "{}", temp_formatter).ok(); + } + } + } + temp + }) + .collect(); + output.write_str(&display_types.join(" & ")) + } Type::Tuple(t) => { t.fmt_with_type(output, &|ty, o| self.fmt_helper_generic(ty, false, o)) } diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index 05a87655af..797b23ef3b 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -396,4 +396,86 @@ mod tests { assert!(str_part.is_some()); assert!(str_part.unwrap().1.is_some()); } + + #[test] + fn test_output_with_locations_union_type_splits_properly() { + // Create int | str | None type + let int_class = fake_class("int", "builtins", 10); + let str_class = fake_class("str", "builtins", 20); + + let int_type = Type::ClassType(ClassType::new(int_class, TArgs::default())); + let str_type = Type::ClassType(ClassType::new(str_class, TArgs::default())); + let union_type = Type::Union(vec![int_type, str_type, Type::None]); + + let ctx = TypeDisplayContext::new(&[&union_type]); + let mut output = OutputWithLocations::new(&ctx); + + ctx.fmt_helper_generic(&union_type, false, &mut output) + .unwrap(); + + let parts_str: String = output.parts().iter().map(|(s, _)| s.as_str()).collect(); + assert_eq!(parts_str, "int | str | None"); + + // New behavior: Union types are split into separate parts + // Expected: [("int", Some(location)), (" | ", None), ("str", Some(location)), (" | ", None), ("None", None)] + let parts = output.parts(); + assert_eq!(parts.len(), 5, "Union should be split into 5 parts"); + + // Verify each part + assert_eq!(parts[0].0, "int"); + assert!(parts[0].1.is_some(), "int should have location"); + + assert_eq!(parts[1].0, " | "); + assert!(parts[1].1.is_none(), "separator should not have location"); + + assert_eq!(parts[2].0, "str"); + assert!(parts[2].1.is_some(), "str should have location"); + + assert_eq!(parts[3].0, " | "); + assert!(parts[3].1.is_none(), "separator should not have location"); + + assert_eq!(parts[4].0, "None"); + assert!(parts[4].1.is_none(), "None should not have location"); + } + + #[test] + fn test_output_with_locations_intersection_type_does_not_split_properly() { + // Create int & str type (doesn't make sense semantically, but tests the formatting) + let int_type = Type::ClassType(ClassType::new( + fake_class("int", "builtins", 10), + TArgs::default(), + )); + let str_type = Type::ClassType(ClassType::new( + fake_class("str", "builtins", 20), + TArgs::default(), + )); + let intersect_type = + Type::Intersect(Box::new((vec![int_type, str_type], Type::any_implicit()))); + + let ctx = TypeDisplayContext::new(&[&intersect_type]); + let mut output = OutputWithLocations::new(&ctx); + + // Format the type using fmt_helper_generic + ctx.fmt_helper_generic(&intersect_type, false, &mut output) + .unwrap(); + + // Check the concatenated result + let parts_str: String = output.parts().iter().map(|(s, _)| s.as_str()).collect(); + assert_eq!(parts_str, "int & str"); + + // Current behavior: The entire intersection is treated as one string + // This is technically incorrect - we want separate parts for each type + // Desired future behavior: [("int", Some(location)), (" & ", None), ("str", Some(location))] + let parts = output.parts(); + assert_eq!( + parts.len(), + 1, + "Current behavior: intersection as single part" + ); + assert_eq!(parts[0].0, "int & str"); + assert!( + parts[0].1.is_none(), + "Current behavior: entire intersection has no location" + ); + } } From 08dfad096b129f4f45eafc4473d9418dc60de771 Mon Sep 17 00:00:00 2001 From: John Van Schultz Date: Mon, 24 Nov 2025 11:31:09 -0800 Subject: [PATCH 60/62] Add test showing that tuple type does not have a location. Summary: Currently if you have a type like `tuple[int]`, the int portion of the type will have a location but the `tuple` portion does not. This diff adds a test to cover this case and will be updated once that functionality is implemented. Also updates the formatting for tuples so that `tuple` and `[` are separate parts rather than just a single `tuple[` part. Reviewed By: kinto0 Differential Revision: D87642082 fbshipit-source-id: dcd0e44785a87ba608ef2431fb0c8a29cae0a816 --- crates/pyrefly_types/src/type_output.rs | 42 +++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/pyrefly_types/src/type_output.rs b/crates/pyrefly_types/src/type_output.rs index 797b23ef3b..423f1b5c87 100644 --- a/crates/pyrefly_types/src/type_output.rs +++ b/crates/pyrefly_types/src/type_output.rs @@ -478,4 +478,46 @@ mod tests { "Current behavior: entire intersection has no location" ); } + + #[test] + fn test_output_with_locations_tuple_base_not_clickable() { + // TODO(jvansch): When implementing clickable support for the base type in generics like tuple[int], + // update this test to verify that "tuple" has a location and is clickable. + // Expected future behavior: [("tuple", Some(location)), ("[", None), ("int", Some(location)), ("]", None)] + + // Create tuple[int] type + let int_class = fake_class("int", "builtins", 10); + let int_type = Type::ClassType(ClassType::new(int_class, TArgs::default())); + let tuple_type = Type::Tuple(Tuple::Concrete(vec![int_type])); + + let ctx = TypeDisplayContext::new(&[&tuple_type]); + let mut output = OutputWithLocations::new(&ctx); + + ctx.fmt_helper_generic(&tuple_type, false, &mut output) + .unwrap(); + + let parts_str: String = output.parts().iter().map(|(s, _)| s.as_str()).collect(); + assert_eq!(parts_str, "tuple[int]"); + + // Current behavior: The "tuple" part is NOT clickable + // Expected parts: [("tuple[", None), ("int", Some(location)), ("]", None)] + let parts = output.parts(); + assert_eq!(parts.len(), 4, "Should have 3 parts"); + + // Verify each part + assert_eq!(parts[0].0, "tuple"); + assert!( + parts[0].1.is_none(), + "tuple[ should not have location (not clickable)" + ); + + assert_eq!(parts[1].0, "["); + assert!(parts[1].1.is_none(), "[ should not have location"); + + assert_eq!(parts[2].0, "int"); + assert!(parts[2].1.is_some(), "int should have location (clickable)"); + + assert_eq!(parts[3].0, "]"); + assert!(parts[3].1.is_none(), "] should not have location"); + } } From 6e4267dc91cf968bac09439d6d8fb8fa4b18cdfa Mon Sep 17 00:00:00 2001 From: Jia Chen Date: Mon, 24 Nov 2025 11:42:03 -0800 Subject: [PATCH 61/62] Fix crashes when `yield` or `yield from` appears in type annotations Summary: dogscience_hotfix Fixes https://github.com/facebook/pyrefly/issues/1669 Reviewed By: rchen152 Differential Revision: D87801231 fbshipit-source-id: cccdac346683fc91d18954d32755ac88e7b72dbb --- pyrefly/lib/test/simple.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyrefly/lib/test/simple.rs b/pyrefly/lib/test/simple.rs index 60c0a0dea0..76691a9c87 100644 --- a/pyrefly/lib/test/simple.rs +++ b/pyrefly/lib/test/simple.rs @@ -1924,6 +1924,7 @@ z: int = y testcase!( test_yield_in_annotation, r#" +from typing import reveal_type def test(): x: (yield 1) # E: "#, @@ -1932,6 +1933,7 @@ def test(): testcase!( test_yield_from_in_annotation, r#" +from typing import reveal_type def test(): x: (yield from [1]) # E: "#, From 02b24baf2d135e36291fb5162b92f7100669ed25 Mon Sep 17 00:00:00 2001 From: Kyle Into Date: Mon, 24 Nov 2025 11:46:11 -0800 Subject: [PATCH 62/62] add tests for last diff Summary: see title Reviewed By: yangdanny97 Differential Revision: D87788532 fbshipit-source-id: b76375ffdacaa2a661c834aca3b6facf10cc71b1 --- crates/pyrefly_python/src/docstring.rs | 233 +++++++++++++++++++++++++ pyrefly/lib/test/lsp/hover.rs | 84 +++++++++ 2 files changed, 317 insertions(+) diff --git a/crates/pyrefly_python/src/docstring.rs b/crates/pyrefly_python/src/docstring.rs index 0ff757ae72..d5ae77d227 100644 --- a/crates/pyrefly_python/src/docstring.rs +++ b/crates/pyrefly_python/src/docstring.rs @@ -450,4 +450,237 @@ Args: assert_eq!(docs.get("foo").unwrap(), "first line\nsecond line"); assert_eq!(docs.get("bar").unwrap(), "final"); } + + #[test] + fn test_parse_sphinx_empty_param() { + let doc = r#" +:param foo: +:param bar: has description +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo"), None); // Empty params should not be included + assert_eq!(docs.get("bar").unwrap(), "has description"); + } + + #[test] + fn test_parse_sphinx_with_type_annotations() { + let doc = r#" +:param int foo: an integer +:param str bar: a string +:param Optional[Dict[str, int]] baz: complex type +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "an integer"); + assert_eq!(docs.get("bar").unwrap(), "a string"); + assert_eq!(docs.get("baz").unwrap(), "complex type"); + } + + #[test] + fn test_parse_sphinx_multiple_continuation_lines() { + let doc = r#" +:param foo: line one + line two + line three + line four +:param bar: single line +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!( + docs.get("foo").unwrap(), + "line one\nline two\nline three\nline four" + ); + assert_eq!(docs.get("bar").unwrap(), "single line"); + } + + #[test] + fn test_parse_sphinx_with_other_directives() { + let doc = r#" +:param foo: the foo parameter +:param bar: the bar parameter +:return: the return value +:raises ValueError: when invalid +:type foo: int +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "the foo parameter"); + assert_eq!(docs.get("bar").unwrap(), "the bar parameter"); + // Other directives should not be included as parameters + assert_eq!(docs.get("return"), None); + assert_eq!(docs.get("raises"), None); + } + + #[test] + fn test_parse_sphinx_with_varargs() { + let doc = r#" +:param *args: positional arguments +:param **kwargs: keyword arguments +:param regular: regular param +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("args").unwrap(), "positional arguments"); + assert_eq!(docs.get("kwargs").unwrap(), "keyword arguments"); + assert_eq!(docs.get("regular").unwrap(), "regular param"); + } + + #[test] + fn test_parse_sphinx_indented_in_docstring() { + let doc = r#""" + Summary of function. + + :param foo: first param + with continuation + :param bar: second param + """#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "first param\nwith continuation"); + assert_eq!(docs.get("bar").unwrap(), "second param"); + } + + #[test] + fn test_parse_google_different_headers() { + let doc = r#" +Arguments: + foo: using Arguments header + bar: second arg + +def another_func(): + """ + Keyword Arguments: + baz: keyword arg + """ +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "using Arguments header"); + assert_eq!(docs.get("bar").unwrap(), "second arg"); + } + + #[test] + fn test_parse_google_parameters_header() { + let doc = r#" +Parameters: + foo (int): first param + bar (str): second param +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "first param"); + assert_eq!(docs.get("bar").unwrap(), "second param"); + } + + #[test] + fn test_parse_google_keyword_args_header() { + let doc = r#" +Keyword Args: + foo: keyword arg one + bar: keyword arg two +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "keyword arg one"); + assert_eq!(docs.get("bar").unwrap(), "keyword arg two"); + } + + #[test] + fn test_parse_google_deeply_indented_continuation() { + let doc = r#" +Args: + foo: first line + second line + third line deeply indented + fourth line + bar: simple +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!( + docs.get("foo").unwrap(), + "first line\nsecond line\nthird line deeply indented\nfourth line" + ); + assert_eq!(docs.get("bar").unwrap(), "simple"); + } + + #[test] + fn test_parse_google_no_type_annotation() { + let doc = r#" +Args: + foo: no type annotation + bar (int): with type annotation + baz: also no type +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "no type annotation"); + assert_eq!(docs.get("bar").unwrap(), "with type annotation"); + assert_eq!(docs.get("baz").unwrap(), "also no type"); + } + + #[test] + fn test_parse_google_complex_type_annotations() { + let doc = r#" +Args: + foo (Optional[List[Dict[str, int]]]): complex type + bar (Callable[[int, str], bool]): callable type +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "complex type"); + assert_eq!(docs.get("bar").unwrap(), "callable type"); + } + + #[test] + fn test_parse_google_section_ends_with_other_section() { + let doc = r#" +Args: + foo: the foo param + bar: the bar param + +Returns: + int: the return value + +Raises: + ValueError: when things go wrong +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "the foo param"); + assert_eq!(docs.get("bar").unwrap(), "the bar param"); + // Returns and Raises should not be parsed as params + assert_eq!(docs.len(), 2); + } + + #[test] + fn test_parse_google_empty_parameter_description() { + let doc = r#" +Args: + foo: + bar: has description +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo"), None); // Empty description should not be included + assert_eq!(docs.get("bar").unwrap(), "has description"); + } + + #[test] + fn test_parse_mixed_sphinx_and_google() { + let doc = r#" +:param sphinx_param: using Sphinx style + with continuation + +Args: + google_param: using Google style + another_google: second Google param +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!( + docs.get("sphinx_param").unwrap(), + "using Sphinx style\nwith continuation" + ); + assert_eq!(docs.get("google_param").unwrap(), "using Google style"); + assert_eq!(docs.get("another_google").unwrap(), "second Google param"); + } + + #[test] + fn test_parse_sphinx_param_with_comma_in_type() { + let doc = r#" +:param Dict[str, int] foo: dict param +:param Tuple[int, str, bool] bar: tuple param +"#; + let docs = parse_parameter_documentation(doc); + assert_eq!(docs.get("foo").unwrap(), "dict param"); + assert_eq!(docs.get("bar").unwrap(), "tuple param"); + } } diff --git a/pyrefly/lib/test/lsp/hover.rs b/pyrefly/lib/test/lsp/hover.rs index b80a16f8b9..747086d555 100644 --- a/pyrefly/lib/test/lsp/hover.rs +++ b/pyrefly/lib/test/lsp/hover.rs @@ -230,6 +230,90 @@ foo(x=1, y=2) assert!(report.contains("documentation for y")); } +#[test] +fn hover_shows_parameter_doc_with_multiline_description() { + let code = r#" +def foo(param: int) -> None: + """ + Args: + param: This is a long parameter description + that spans multiple lines + with detailed information + """ + ... + +foo(param=1) +# ^ +"#; + let report = get_batched_lsp_operations_report(&[("main", code)], get_test_report); + assert!(report.contains("**Parameter `param`**")); + assert!(report.contains("This is a long parameter description")); + assert!(report.contains("that spans multiple lines")); + assert!(report.contains("with detailed information")); +} + +#[test] +fn hover_on_parameter_definition_shows_doc() { + let code = r#" +def foo(param: int) -> None: + """ + Args: + param: documentation for param + """ + print(param) +# ^ +"#; + let report = get_batched_lsp_operations_report(&[("main", code)], get_test_report); + assert!( + report.contains("**Parameter `param`**"), + "Expected parameter doc when hovering on parameter usage, got: {report}" + ); + assert!(report.contains("documentation for param")); +} + +#[test] +fn hover_parameter_doc_with_type_annotations_in_docstring() { + let code = r#" +def foo(x, y): + """ + Args: + x (int): an integer parameter + y (str): a string parameter + """ + ... + +foo(x=1, y="hello") +# ^ +foo(x=1, y="hello") +# ^ +"#; + let report = get_batched_lsp_operations_report(&[("main", code)], get_test_report); + assert!(report.contains("**Parameter `x`**")); + assert!(report.contains("an integer parameter")); + assert!(report.contains("**Parameter `y`**")); + assert!(report.contains("a string parameter")); +} + +#[test] +fn hover_parameter_doc_with_complex_types() { + let code = r#" +from typing import Optional, List, Dict + +def foo(data: Optional[List[Dict[str, int]]]) -> None: + """ + Args: + data: complex nested type parameter + """ + ... + +foo(data=[]) +# ^ +"#; + let report = get_batched_lsp_operations_report(&[("main", code)], get_test_report); + assert!(report.contains("**Parameter `data`**")); + assert!(report.contains("complex nested type parameter")); +} + #[test] fn hover_over_overloaded_binary_operator_shows_dunder_name() { let code = r#"