From 986c7f8a4531eab73726dbf5f52002386cee21dd Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Sun, 30 Aug 2026 11:32:19 +0000 Subject: [PATCH 1/6] feat(lsp): complete closing triple-quoted strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offer a snippet (or plain text) completion that expands a 1–2 quote run into a triple-quoted pair, or inserts the closer after an opening """. Matches the ty completion request in facebook/pyrefly#4346. --- pyrefly/lib/lsp/wasm/completion.rs | 97 ++++++++++++++++++++++ pyrefly/lib/test/lsp/completion.rs | 129 +++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) diff --git a/pyrefly/lib/lsp/wasm/completion.rs b/pyrefly/lib/lsp/wasm/completion.rs index 6fb093fdf1..cfedce14a3 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::CompletionTextEdit; use lsp_types::InsertTextFormat; use lsp_types::TextEdit; use pyrefly_build::handle::Handle; @@ -354,6 +355,96 @@ impl Transaction<'_> { } } + /// Offers to close (or expand into) a triple-quoted string after the opening quotes. + 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 == 0 { + 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; + } + } + } + let closer = quote.to_string().repeat(3); + if source[pos..].starts_with(&closer) { + return; + } + let (edit_start, new_text) = if count < 3 { + let opener_and_closer = if supports_snippets { + format!("{closer}$0{closer}") + } else { + format!("{closer}{closer}") + }; + (quote_start, opener_and_closer) + } else if supports_snippets { + (pos, format!("$0{closer}")) + } else { + (pos, closer.clone()) + }; + let Some(start) = TextSize::try_from(edit_start).ok() else { + return; + }; + let range = module_info.to_lsp_range(TextRange::new(start, 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(CompletionTextEdit::Edit(TextEdit { range, new_text })), + ..Default::default() + })); + } + /// Retrieves documentation for an export to display in completion items. fn get_documentation_from_export( &self, @@ -1089,6 +1180,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 61abf2ad0b..189ad2b94d 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; @@ -4924,3 +4925,131 @@ 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_expands_partial_quotes_to_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("new_text: \"\\\"\\\"\\\"$0"), + "expected expansion to a triple-quoted pair:\n{trimmed}" + ); +} + +#[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}" + ); +} From fccff3b72d3fe44bde3802f159b879a4175cf4e6 Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Fri, 11 Sep 2026 05:19:10 +0000 Subject: [PATCH 2/6] Only close triple-quoted strings after a three-quote opener A one- or two-quote run is already an open string, so expanding it into a triple-quoted snippet stole Literal, dict-key, and kwarg completions. Offer the closer only after """ / '''. --- pyrefly/lib/lsp/wasm/completion.rs | 26 ++++++++++---------------- pyrefly/lib/test/lsp/completion.rs | 9 +++++---- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/pyrefly/lib/lsp/wasm/completion.rs b/pyrefly/lib/lsp/wasm/completion.rs index cfedce14a3..d5cdf00597 100644 --- a/pyrefly/lib/lsp/wasm/completion.rs +++ b/pyrefly/lib/lsp/wasm/completion.rs @@ -355,7 +355,11 @@ impl Transaction<'_> { } } - /// Offers to close (or expand into) a triple-quoted string after the opening quotes. + /// 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, @@ -386,7 +390,7 @@ impl Transaction<'_> { break; } } - if count == 0 { + if count != 3 { return; } let quote_start = pos - count; @@ -419,22 +423,12 @@ impl Transaction<'_> { if source[pos..].starts_with(&closer) { return; } - let (edit_start, new_text) = if count < 3 { - let opener_and_closer = if supports_snippets { - format!("{closer}$0{closer}") - } else { - format!("{closer}{closer}") - }; - (quote_start, opener_and_closer) - } else if supports_snippets { - (pos, format!("$0{closer}")) + let new_text = if supports_snippets { + format!("$0{closer}") } else { - (pos, closer.clone()) - }; - let Some(start) = TextSize::try_from(edit_start).ok() else { - return; + closer.clone() }; - let range = module_info.to_lsp_range(TextRange::new(start, position)); + 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()), diff --git a/pyrefly/lib/test/lsp/completion.rs b/pyrefly/lib/test/lsp/completion.rs index 189ad2b94d..a7a715b583 100644 --- a/pyrefly/lib/test/lsp/completion.rs +++ b/pyrefly/lib/test/lsp/completion.rs @@ -4990,19 +4990,20 @@ x = """ } #[test] -fn completion_expands_partial_quotes_to_triple_quoted_string() { +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), ); - let trimmed = report.trim(); assert!( - trimmed.contains("new_text: \"\\\"\\\"\\\"$0"), - "expected expansion to a triple-quoted pair:\n{trimmed}" + !report.contains("triple-quoted string"), + "single/double quotes are already-open strings, not a triple-quoted opener:\n{report}" ); } From 1fb52b7ef41ed514d2ba2927e6fc84c30ebcb451 Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Wed, 30 Sep 2026 08:46:06 +0000 Subject: [PATCH 3/6] Adapt triple-quoted string completions to gen-lsp-types After rebasing onto main, the feature still used the old lsp-types names (SNIPPET / CompletionTextEdit), which broke the build once #5032 switched the crate to gen-lsp-types. Use Snippet and CompletionItemTextEdit so the branch compiles again. Co-authored-by: Mangesh Raut --- pyrefly/lib/lsp/wasm/completion.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pyrefly/lib/lsp/wasm/completion.rs b/pyrefly/lib/lsp/wasm/completion.rs index d5cdf00597..48ca77f977 100644 --- a/pyrefly/lib/lsp/wasm/completion.rs +++ b/pyrefly/lib/lsp/wasm/completion.rs @@ -12,7 +12,7 @@ use lsp_types::CompletionItem; use lsp_types::CompletionItemKind; use lsp_types::CompletionItemLabelDetails; use lsp_types::CompletionItemTag; -use lsp_types::CompletionTextEdit; +use lsp_types::CompletionItemTextEdit; use lsp_types::InsertTextFormat; use lsp_types::TextEdit; use pyrefly_build::handle::Handle; @@ -432,9 +432,12 @@ impl Transaction<'_> { 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(CompletionTextEdit::Edit(TextEdit { range, new_text })), + kind: Some(CompletionItemKind::Snippet), + insert_text_format: supports_snippets.then_some(InsertTextFormat::Snippet), + text_edit: Some(CompletionItemTextEdit::TextEdit(TextEdit { + range, + new_text, + })), ..Default::default() })); } From df0929bedc2f0d7ce96a18cf1fe8054123539d28 Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Sat, 10 Oct 2026 12:14:40 +0000 Subject: [PATCH 4/6] Skip triple-quote closer when quotes close a string or are in a comment --- pyrefly/lib/lsp/wasm/completion.rs | 57 ++++++++++++++++++++++++ pyrefly/lib/test/lsp/completion.rs | 69 ++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/pyrefly/lib/lsp/wasm/completion.rs b/pyrefly/lib/lsp/wasm/completion.rs index 48ca77f977..a1ad52c142 100644 --- a/pyrefly/lib/lsp/wasm/completion.rs +++ b/pyrefly/lib/lsp/wasm/completion.rs @@ -419,6 +419,9 @@ impl Transaction<'_> { } } } + 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; @@ -442,6 +445,60 @@ impl Transaction<'_> { })); } + /// 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, diff --git a/pyrefly/lib/test/lsp/completion.rs b/pyrefly/lib/test/lsp/completion.rs index a7a715b583..744d0f279e 100644 --- a/pyrefly/lib/test/lsp/completion.rs +++ b/pyrefly/lib/test/lsp/completion.rs @@ -5054,3 +5054,72 @@ x = f""" "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}" + ); +} From 5d47ccf0d59a7995d73bfb448cca7b5275537f03 Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Sun, 25 Oct 2026 20:52:22 +0000 Subject: [PATCH 5/6] ci: retrigger checks after unrelated macOS flake From d8e3ac9c10090055d0a536b16257ad77c69217cb Mon Sep 17 00:00:00 2001 From: Mangesh Raut Date: Mon, 26 Oct 2026 23:05:02 +0530 Subject: [PATCH 6/6] fix(test): make coverage pipe-close checks portable on macOS BSD head rejects -n 0; use dd bs=1 count=0 so the scrut pipe-close examples stay portable (same as #5390). --- test/coverage.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/coverage.md b/test/coverage.md index 1119decf95..c89d934b7b 100644 --- a/test/coverage.md +++ b/test/coverage.md @@ -56,7 +56,7 @@ IDENTICAL ```scrut $ touch $TMPDIR/pyrefly.toml && \ > for i in $(seq 1 1000); do echo "x$i: int = 1"; done > $TMPDIR/large.py && \ -> set -o pipefail && $PYREFLY coverage report $TMPDIR/large.py 2>/dev/null | head -n 0 +> set -o pipefail && $PYREFLY coverage report $TMPDIR/large.py 2>/dev/null | dd bs=1 count=0 2>/dev/null [0] ``` @@ -65,6 +65,6 @@ $ touch $TMPDIR/pyrefly.toml && \ ```scrut $ touch $TMPDIR/pyrefly.toml && \ > for i in $(seq 1 1000); do echo "x$i: int = 1"; done > $TMPDIR/large.py && \ -> set -o pipefail && $PYREFLY report $TMPDIR/large.py 2>/dev/null | head -n 0 +> set -o pipefail && $PYREFLY report $TMPDIR/large.py 2>/dev/null | dd bs=1 count=0 2>/dev/null [0] ```