Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 5 additions & 12 deletions crates/pyrefly_config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4673,7 +4673,7 @@ output-format = "omit-errors"
// File contents should still be relative to the location of the config file, not src/.
assert_eq!(
config.project_includes,
Globs::new(vec![root.path().join("**/*").to_string_lossy().to_string()]).unwrap(),
Globs::new_with_root(root.path(), vec!["**/*".to_owned()]).unwrap(),
);
assert_eq!(
config.search_path().cloned().collect::<Vec<_>>(),
Expand Down Expand Up @@ -5494,15 +5494,14 @@ matches = "generated/**"
let (config, errors) = ConfigFile::from_file(&child);

assert!(errors.is_empty());
let shared_glob = |pattern: &str| {
Globs::new(vec![shared.join(pattern).to_string_lossy().into_owned()]).unwrap()
};
let shared_glob =
|pattern: &str| Globs::new_with_root(&shared, vec![pattern.to_owned()]).unwrap();
assert_eq!(config.project_includes, shared_glob("base/**/*.py"));
assert_eq!(config.search_path_from_file, vec![shared.join("stubs")]);
assert_eq!(config.coverage.includes, Some(shared_glob("covered")));
assert_eq!(
config.sub_configs[0].matches,
Glob::new(shared.join("generated/**").to_string_lossy().into_owned()).unwrap()
Glob::new_with_root(&shared, "generated/**".to_owned()).unwrap()
);
assert_eq!(
config.python_environment.site_package_path,
Expand Down Expand Up @@ -5538,13 +5537,7 @@ search-path = []
assert!(errors.is_empty());
assert_eq!(
config.project_includes,
Globs::new(vec![
root.path()
.join("child/**/*.py")
.to_string_lossy()
.to_string()
])
.unwrap()
Globs::new_with_root(root.path(), vec!["child/**/*.py".to_owned()]).unwrap()
);
assert!(config.search_path_from_file.is_empty());
}
Expand Down
1 change: 1 addition & 0 deletions pyrefly/lib/lsp/non_wasm/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5312,6 +5312,7 @@ impl Server {
&handle,
range,
import_format,
lsp_config.and_then(|c| c.inlay_hints).unwrap_or_default(),
Some(&self.lsp_thread_pool),
) {
actions.extend(quickfixes.into_iter().filter_map(|(title, edits)| {
Expand Down
120 changes: 96 additions & 24 deletions pyrefly/lib/lsp/wasm/inlay_hints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use ruff_python_ast::ExprDict;
use ruff_python_ast::ExprList;
use ruff_python_ast::ModModule;
use ruff_python_ast::ParameterWithDefault;
use ruff_python_ast::Stmt;
use ruff_python_ast::name::Name;
use ruff_text_size::Ranged;
use ruff_text_size::TextRange;
Expand Down Expand Up @@ -118,6 +119,30 @@ impl<'a> Transaction<'a> {
handle: &Handle,
inlay_hint_config: InlayHintConfig,
import_format: ImportFormat,
) -> Option<Vec<InlayHintData>> {
self.inlay_hints_impl(handle, inlay_hint_config, import_format, None)
}

/// Inlay hints whose annotated declaration overlaps `range`.
///
/// Types outside `range` are not rendered. Code actions use this so a cursor
/// request does not format every hint in the file.
pub fn inlay_hints_in_range(
&self,
handle: &Handle,
inlay_hint_config: InlayHintConfig,
import_format: ImportFormat,
range: TextRange,
) -> Option<Vec<InlayHintData>> {
self.inlay_hints_impl(handle, inlay_hint_config, import_format, Some(range))
}

fn inlay_hints_impl(
&self,
handle: &Handle,
inlay_hint_config: InlayHintConfig,
import_format: ImportFormat,
limit: Option<TextRange>,
) -> Option<Vec<InlayHintData>> {
let is_interesting = |e: &Expr, ty: &Type, class_name: Option<&Name>| {
!ty.is_any()
Expand Down Expand Up @@ -186,40 +211,49 @@ impl<'a> Transaction<'a> {
edits,
}
};
// `None` keeps every hint. An empty cursor includes the insertion point at
// the end of the declaration. A nonempty selection uses half-open overlap,
// so a declaration that only touches the selection's exclusive end is left out.
let in_selection = |target: TextRange| match limit {
None => true,
Some(selection) if selection.is_empty() => {
selection.start() >= target.start() && selection.start() <= target.end()
}
Some(selection) => selection.start() < target.end() && target.start() < selection.end(),
};
let mut res = Vec::new();
for idx in bindings.keys::<Key>() {
match bindings.idx_to_key(idx) {
Key::ReturnType(id) if inlay_hint_config.function_return_types => {
match bindings.get(bindings.key_to_idx(&Key::Definition(*id))) {
Binding::Function { decorated_idx, .. } => {
if matches!(&bindings.get(idx), Binding::ReturnType(ret) if !ret.kind.has_return_annotation())
&& let Some(mut ty) = answers.get_type_at(idx)
&& !ty.is_any()
if let Binding::Function { decorated_idx, .. } =
bindings.get(bindings.key_to_idx(&Key::Definition(*id)))
{
let fun = bindings.get(bindings.get(*decorated_idx).undecorated_idx);
let is_async = fun.def.is_async;
let position = fun.def.parameters.range.end();
// The header runs from the name through the parameter list, so a
// cursor in the body does not offer the return annotation.
let header = TextRange::new(fun.def.name.range.start(), position);
if in_selection(header)
&& matches!(&bindings.get(idx), Binding::ReturnType(ret) if !ret.kind.has_return_annotation())
&& let Some(mut ty) = answers.get_type_at(idx)
&& !ty.is_any()
{
if is_async
&& let Some(Some((_, _, return_ty))) =
self.ad_hoc_solve(handle, "inlay_hint_coroutine", |solver| {
solver.unwrap_coroutine(&ty)
})
{
let fun =
bindings.get(bindings.get(*decorated_idx).undecorated_idx);
if fun.def.is_async
&& let Some(Some((_, _, return_ty))) = self.ad_hoc_solve(
handle,
"inlay_hint_coroutine",
|solver| solver.unwrap_coroutine(&ty),
)
{
ty = return_ty;
}
res.push(make_type_hint(
" -> ",
fun.def.parameters.range.end(),
&ty,
true,
));
ty = return_ty;
}
res.push(make_type_hint(" -> ", position, &ty, true));
}
_ => {}
}
}
key @ Key::Definition(_)
if inlay_hint_config.variable_types
&& in_selection(key.range())
&& let Some(ty) = answers.get_type_at(idx) =>
{
let declaration_type = answers.get_class_field_type_at_definition(idx);
Expand Down Expand Up @@ -247,7 +281,14 @@ impl<'a> Transaction<'a> {
// receiver case, could mislead users into
// annotating with the RHS-derived type. The same
// applies to receiver-bearing unpacked rebinds.
Binding::NameAssign(x) if !x.is_pinned() => (Some(&*x.expr), false),
Binding::NameAssign(x) if !x.is_pinned() => {
// `global` and `nonlocal` names cannot take an annotated assignment.
if self.capture_forbids_annotation(handle, key.range().start(), &x.name)
{
insertable = false;
}
(Some(&*x.expr), false)
}
Binding::Expr(None, e) => {
// Expression-bound names, such as walrus targets, cannot be annotated inline.
insertable = false;
Expand Down Expand Up @@ -303,6 +344,9 @@ impl<'a> Transaction<'a> {
if inlay_hint_config.variable_types {
for field_idx in bindings.keys::<KeyClassField>() {
let field = bindings.get(field_idx);
if !in_selection(field.range) {
continue;
}
if let ClassFieldDefinition::DefinedInMethod {
values,
annotation: None,
Expand Down Expand Up @@ -354,6 +398,7 @@ impl<'a> Transaction<'a> {
res.extend(
self.add_inlay_hints_for_positional_function_args(handle)
.into_iter()
.filter(|(pos, _)| in_selection(TextRange::empty(*pos)))
.map(|(pos, text)| InlayHintData {
position: pos,
label_parts: vec![(text.clone(), None)],
Expand All @@ -368,6 +413,33 @@ impl<'a> Transaction<'a> {
Some(res)
}

/// `global` and `nonlocal` names cannot take an annotated assignment in that scope.
fn capture_forbids_annotation(&self, handle: &Handle, position: TextSize, name: &Name) -> bool {
fn declared(stmts: &[Stmt], position: TextSize, name: &Name) -> Option<bool> {
for stmt in stmts {
let body = match stmt {
Stmt::FunctionDef(func) if func.range().contains(position) => {
func.body.as_slice()
}
Stmt::ClassDef(class) if class.range().contains(position) => {
class.body.as_slice()
}
_ => continue,
};
return Some(declared(body, position, name).unwrap_or_else(|| {
body.iter().any(|stmt| match stmt {
Stmt::Global(global) => global.names.iter().any(|id| id.id == *name),
Stmt::Nonlocal(nonlocal) => nonlocal.names.iter().any(|id| id.id == *name),
_ => false,
})
}));
}
None
}
self.get_ast(handle)
.is_some_and(|ast| declared(&ast.body, position, name).unwrap_or(false))
}

/// Helper to extract the element expression from an unpacked source.
/// Returns the expression at the given position if the source is a tuple or list literal.
/// For nested unpacking or function calls, returns None (caller should fall back to
Expand Down
12 changes: 12 additions & 0 deletions pyrefly/lib/state/lsp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3805,6 +3805,7 @@ impl<'a> Transaction<'a> {
handle: &Handle,
range: TextRange,
import_format: ImportFormat,
inlay_hint_config: InlayHintConfig,
custom_thread_pool: Option<&ThreadPool>,
) -> Option<Vec<(String, Vec<(Module, TextRange, String)>)>> {
let module_info = self.get_module_info(handle)?;
Expand Down Expand Up @@ -4079,6 +4080,17 @@ impl<'a> Transaction<'a> {
_ => {}
}
}
if let Some(actions) =
quick_fixes::insert_inferred_annotation::insert_inferred_annotation_code_actions(
self,
handle,
range,
import_format,
inlay_hint_config,
)
{
multi_actions.extend(actions);
}
if let Some(answers) = self.get_answers(handle) {
let bindings = answers.bindings();
for unused in bindings.unused_variables() {
Expand Down
1 change: 1 addition & 0 deletions pyrefly/lib/state/lsp/quick_fixes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub(crate) mod implicit_import;
pub(crate) mod inline_method;
pub(crate) mod inline_parameter;
pub(crate) mod inline_variable;
pub(crate) mod insert_inferred_annotation;
pub(crate) mod introduce_parameter;
pub(crate) mod invert_boolean;
pub(crate) mod move_members;
Expand Down
64 changes: 64 additions & 0 deletions pyrefly/lib/state/lsp/quick_fixes/insert_inferred_annotation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

use dupe::Dupe;
use pyrefly_build::handle::Handle;
use pyrefly_python::module::Module;
use ruff_text_size::TextRange;

use crate::state::lsp::AllOffPartial;
use crate::state::lsp::ImportFormat;
use crate::state::lsp::InlayHintConfig;
use crate::state::state::Transaction;

/// Offer the insertable inlay hints that overlap `selection` as quick fixes.
///
/// Each action applies that hint's existing edits: the annotation at the hint
/// position, plus any imports the annotation needs. Call-argument name hints
/// are not type annotations, so they are not requested.
pub(crate) fn insert_inferred_annotation_code_actions(
transaction: &Transaction<'_>,
handle: &Handle,
selection: TextRange,
import_format: ImportFormat,
mut inlay_hint_config: InlayHintConfig,
) -> Option<Vec<(String, Vec<(Module, TextRange, String)>)>> {
if !inlay_hint_config.variable_types && !inlay_hint_config.function_return_types {
return None;
}
inlay_hint_config.call_argument_names = AllOffPartial::Off;
let hints =
transaction.inlay_hints_in_range(handle, inlay_hint_config, import_format, selection)?;
let module = transaction.get_module_info(handle)?;
let mut actions = Vec::new();
for hint in hints {
let Some(edits) = hint.edits else {
continue;
};
let title = format!("Insert inferred type `{}`", edits.annotation.trim());
let mut action_edits = Vec::with_capacity(1 + edits.imports.len());
action_edits.push((
module.dupe(),
TextRange::empty(hint.position),
edits.annotation,
));
for (offset, import_text) in edits.imports {
action_edits.push((module.dupe(), TextRange::empty(offset), import_text));
}
actions.push((hint.position, title, action_edits));
}
if actions.is_empty() {
return None;
}
actions.sort_by_key(|(position, _, _)| *position);
Some(
actions
.into_iter()
.map(|(_, title, edits)| (title, edits))
.collect(),
)
}
Loading
Loading