diff --git a/pyrefly/lib/lsp/wasm/completion.rs b/pyrefly/lib/lsp/wasm/completion.rs index 93980e4695..d9dcc2ad53 100644 --- a/pyrefly/lib/lsp/wasm/completion.rs +++ b/pyrefly/lib/lsp/wasm/completion.rs @@ -12,6 +12,7 @@ use lsp_types::CompletionItem; use lsp_types::CompletionItemKind; use lsp_types::CompletionItemLabelDetails; use lsp_types::CompletionItemTag; +use lsp_types::CompletionItemTextEdit; use lsp_types::InsertTextFormat; use lsp_types::TextEdit; use pyrefly_build::handle::Handle; @@ -354,6 +355,150 @@ impl Transaction<'_> { } } + /// Offers to insert the closer after an opening triple-quoted string. + /// + /// Only a run of exactly three quotes is treated as that opener. A single or + /// double quote is already an open string (Literal values, dict keys, kwargs), + /// so expanding those would steal the completions that belong in that context. + fn add_triple_quoted_string_completions( + &self, + handle: &Handle, + position: TextSize, + supports_snippets: bool, + completions: &mut Vec, + ) { + let Some(module_info) = self.get_module_info(handle) else { + return; + }; + let source = module_info.contents(); + let pos = position.to_usize(); + if pos == 0 || pos > source.len() { + return; + } + let before = &source[..pos]; + let Some(quote) = before.chars().next_back() else { + return; + }; + if quote != '"' && quote != '\'' { + return; + } + let mut count = 0usize; + for c in before.chars().rev() { + if c == quote && count < 3 { + count += 1; + } else { + break; + } + } + if count != 3 { + return; + } + let quote_start = pos - count; + if quote_start > 0 && before.as_bytes()[quote_start - 1] == quote as u8 { + return; + } + if quote_start > 0 { + let prev = before.as_bytes()[quote_start - 1]; + if prev.is_ascii_alphanumeric() || prev == b'_' { + let mut ident_start = quote_start; + while ident_start > 0 { + let b = before.as_bytes()[ident_start - 1]; + if b.is_ascii_alphanumeric() || b == b'_' { + ident_start -= 1; + } else { + break; + } + } + let ident = before[ident_start..quote_start].to_ascii_lowercase(); + let is_string_prefix = matches!( + ident.as_str(), + "r" | "u" | "f" | "b" | "fr" | "rf" | "br" | "rb" + ); + if !is_string_prefix { + return; + } + } + } + if Self::is_inside_string_or_comment(&before[..quote_start]) { + return; + } + let closer = quote.to_string().repeat(3); + if source[pos..].starts_with(&closer) { + return; + } + let new_text = if supports_snippets { + format!("$0{closer}") + } else { + closer.clone() + }; + let range = module_info.to_lsp_range(TextRange::new(position, position)); + completions.push(RankedCompletion::new(CompletionItem { + label: closer, + detail: Some("triple-quoted string".to_owned()), + kind: Some(CompletionItemKind::Snippet), + insert_text_format: supports_snippets.then_some(InsertTextFormat::Snippet), + text_edit: Some(CompletionItemTextEdit::TextEdit(TextEdit { + range, + new_text, + })), + ..Default::default() + })); + } + + /// Returns true if the end of `source` lies inside a string literal or a comment, + /// so quotes typed there close a string or are comment text rather than open one. + fn is_inside_string_or_comment(source: &str) -> bool { + let bytes = source.as_bytes(); + let mut i = 0; + let mut in_comment = false; + let mut string: Option<(u8, bool)> = None; + while i < bytes.len() { + let b = bytes[i]; + if in_comment { + if b == b'\n' { + in_comment = false; + } + i += 1; + continue; + } + match string { + Some((q, triple)) => { + if b == b'\\' { + i += 2; + continue; + } + if triple { + if bytes[i..].starts_with(&[q, q, q]) { + string = None; + i += 3; + continue; + } + } else if b == q || b == b'\n' { + string = None; + } + i += 1; + } + None => { + if b == b'#' { + in_comment = true; + i += 1; + } else if b == b'"' || b == b'\'' { + if bytes[i..].starts_with(&[b, b, b]) { + string = Some((b, true)); + i += 3; + } else { + string = Some((b, false)); + i += 1; + } + } else { + i += 1; + } + } + } + } + in_comment || string.is_some() + } + /// Retrieves documentation for an export to display in completion items. fn get_documentation_from_export( &self, @@ -1097,6 +1242,12 @@ impl Transaction<'_> { auto_import, } = options; let mut result: Vec = Vec::new(); + self.add_triple_quoted_string_completions( + handle, + position, + supports_snippet_completions, + &mut result, + ); let mut is_incomplete = false; let mut allow_function_call_parens = false; let ast = self.get_ast(handle); diff --git a/pyrefly/lib/test/lsp/completion.rs b/pyrefly/lib/test/lsp/completion.rs index 5725ebc119..2f6773fd72 100644 --- a/pyrefly/lib/test/lsp/completion.rs +++ b/pyrefly/lib/test/lsp/completion.rs @@ -13,6 +13,7 @@ use pyrefly_build::handle::Handle; use pyrefly_python::sys_info::PythonVersion; use ruff_text_size::TextSize; +use crate::lsp::wasm::completion::CompletionOptions; use crate::state::lsp::ImportFormat; use crate::state::require::Require; use crate::state::state::State; @@ -5296,3 +5297,231 @@ def users() -> None: "missing status_code in completions:\n{trimmed}" ); } +#[test] +fn dataframe_column_completion_from_lazy_groupby_agg() { + let code = r#" +import polars as pl +df = pl.DataFrame({"nm_produto": ["A"], "volume": [2]}) +df.lazy().group_by("nm_produto").agg(pl.col("")) +# ^ +"#; + assert_eq!( + polars_column_completion_labels(code), + vec!["nm_produto".to_owned(), "volume".to_owned()] + ); +} + +#[test] +fn dataframe_column_completion_groupby_agg_non_polars() { + let code = r#" +class Custom: + def group_by(self, key: str) -> "Custom": + return self + + def agg(self, value: str) -> None: + pass + +obj = Custom() +obj.group_by("category").agg("") +# ^ +"#; + assert_eq!(polars_column_completion_labels(code), Vec::::new()); +} + +fn get_triple_quoted_string_report( + supports_snippets: bool, +) -> impl Fn(&State, &Handle, TextSize) -> String { + move |state: &State, handle: &Handle, position: TextSize| { + let mut report = "Completion Results:".to_owned(); + for item in state + .transaction() + .completion_with_incomplete( + handle, + position, + ImportFormat::Absolute, + CompletionOptions { + supports_snippet_completions: supports_snippets, + ..Default::default() + }, + None, + ) + .0 + { + if item.detail.as_deref() != Some("triple-quoted string") { + continue; + } + report.push_str("\n- ("); + report.push_str(&format!("{:?}", item.kind.unwrap())); + report.push_str(") "); + report.push_str(&item.label); + if let Some(detail) = item.detail { + report.push_str(": "); + report.push_str(&detail); + } + if let Some(text_edit) = item.text_edit { + report.push_str(" with text edit: "); + report.push_str(&format!("{:?}", text_edit)); + } + if let Some(fmt) = item.insert_text_format { + report.push_str(&format!(" format={fmt:?}")); + } + } + report + } +} + +#[test] +fn completion_closes_triple_quoted_string() { + let code = r#" +x = """ +# ^ +"#; + let report = get_batched_lsp_operations_report_allow_error( + &[("main", code)], + get_triple_quoted_string_report(true), + ); + let trimmed = report.trim(); + assert!( + trimmed.contains("triple-quoted string"), + "missing triple-quoted string completion:\n{trimmed}" + ); + assert!( + trimmed.contains("new_text: \"$0"), + "expected snippet closer after opening quotes:\n{trimmed}" + ); +} + +#[test] +fn completion_skips_partial_quotes_inside_open_string() { + let code = r#" +x = " +# ^ +y = "" +# ^ +"#; + let report = get_batched_lsp_operations_report_allow_error( + &[("main", code)], + get_triple_quoted_string_report(true), + ); + assert!( + !report.contains("triple-quoted string"), + "single/double quotes are already-open strings, not a triple-quoted opener:\n{report}" + ); +} + +#[test] +fn completion_skips_triple_quoted_string_when_closer_exists() { + let code = r#" +x = """""" +# ^ +"#; + let report = get_batched_lsp_operations_report_allow_error( + &[("main", code)], + get_triple_quoted_string_report(false), + ); + assert!( + !report.contains("triple-quoted string"), + "should not offer a closer when one is already present:\n{report}" + ); +} + +#[test] +fn completion_skips_quotes_after_identifier() { + let code = r#" +foo" +# ^ +"#; + let report = get_batched_lsp_operations_report_allow_error( + &[("main", code)], + get_triple_quoted_string_report(false), + ); + assert!( + !report.contains("triple-quoted string"), + "should not treat identifier-adjacent quotes as a string opener:\n{report}" + ); +} + +#[test] +fn completion_closes_prefixed_triple_quoted_string() { + let code = r#" +x = f""" +# ^ +"#; + let report = get_batched_lsp_operations_report_allow_error( + &[("main", code)], + get_triple_quoted_string_report(false), + ); + assert!( + report.contains("triple-quoted string"), + "missing closer after an f-string prefix:\n{report}" + ); +} + +#[test] +fn completion_skips_closing_triple_quoted_string() { + let code = r#" +def f(): + """ + text + """ +# ^ +"#; + let report = get_batched_lsp_operations_report_allow_error( + &[("main", code)], + get_triple_quoted_string_report(false), + ); + assert!( + !report.contains("triple-quoted string"), + "should not offer a closer for quotes that close a docstring:\n{report}" + ); +} + +#[test] +fn completion_skips_triple_quotes_inside_string() { + let code = r#" +x = 'say """ +# ^ +"#; + let report = get_batched_lsp_operations_report_allow_error( + &[("main", code)], + get_triple_quoted_string_report(false), + ); + assert!( + !report.contains("triple-quoted string"), + "should not offer a closer for quotes inside another string:\n{report}" + ); +} + +#[test] +fn completion_skips_triple_quotes_in_comment() { + let code = r#" +x = 1 # note """ +# ^ +"#; + let report = get_batched_lsp_operations_report_allow_error( + &[("main", code)], + get_triple_quoted_string_report(false), + ); + assert!( + !report.contains("triple-quoted string"), + "should not offer a closer for quotes in a comment:\n{report}" + ); +} + +#[test] +fn completion_closes_triple_quoted_string_after_closed_docstring() { + let code = r#" +def f(): + """doc""" + y = """ +# ^ +"#; + let report = get_batched_lsp_operations_report_allow_error( + &[("main", code)], + get_triple_quoted_string_report(false), + ); + assert!( + report.contains("triple-quoted string"), + "missing closer after an earlier closed docstring:\n{report}" + ); +}