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
89 changes: 87 additions & 2 deletions pyrefly/lib/error/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ use std::path::Path;
use itertools::Itertools;
use lsp_types::CodeDescription;
use lsp_types::Diagnostic;
use lsp_types::DiagnosticRelatedInformation;
use lsp_types::DiagnosticTag;
use lsp_types::Location;
use lsp_types::Url;
use pyrefly_python::ignore::Tool;
use pyrefly_python::module::Module;
Expand All @@ -35,6 +37,7 @@ use yansi::Paint;

use crate::config::error_kind::ErrorKind;
use crate::config::error_kind::Severity;
use crate::lsp::module_helpers::to_real_path;

/// A secondary annotation that labels a span in the same file as the primary error.
/// Used to show additional context, e.g. the types of both operands in a binary operation.
Expand Down Expand Up @@ -322,8 +325,24 @@ impl Error {
let code_description = Url::parse(&self.error_kind().docs_url())
.ok()
.map(|href| CodeDescription { href });
// TODO: Map secondary_annotations to DiagnosticRelatedInformation for LSP clients.
// This requires constructing a Url from the module path, which may not always succeed.
// Secondary annotations live in the same file as the primary error, so we can reuse this
// module's path for their locations. `Url::from_file_path` requires an absolute path and
// may fail; in that case we omit the related information rather than dropping the diagnostic.
let related_information = to_real_path(self.module.path())
.and_then(|path| Url::from_file_path(path).ok())
.map(|uri| {
self.secondary_annotations
.iter()
.map(|ann| DiagnosticRelatedInformation {
location: Location {
uri: uri.clone(),
range: self.module.to_lsp_range(ann.range),
},
message: ann.label.to_string(),
})
.collect::<Vec<_>>()
})
.filter(|related| !related.is_empty());
Diagnostic {
range: self.module.to_lsp_range(self.range()),
severity: Some(match self.severity() {
Expand All @@ -342,6 +361,7 @@ impl Error {
} else {
None
},
related_information,
..Default::default()
}
}
Expand Down Expand Up @@ -636,6 +656,71 @@ mod tests {
);
}

#[test]
fn test_to_diagnostic_maps_secondary_annotations() {
// Use an absolute path so `Url::from_file_path` succeeds.
let source = "val * 2";
let module_info = Module::new(
ModuleName::from_str("test"),
ModulePath::filesystem(PathBuf::from("/test.py")),
Arc::new(source.to_owned()),
);
let error = Error::new(
module_info,
TextRange::new(TextSize::new(0), TextSize::new(7)),
"`*` is not supported between `int | str` and `int`".to_owned(),
Vec::new(),
ErrorKind::UnsupportedOperation,
)
.with_annotation(
TextRange::new(TextSize::new(0), TextSize::new(3)),
"has type `int | str`".to_owned(),
)
.with_annotation(
TextRange::new(TextSize::new(6), TextSize::new(7)),
"has type `int`".to_owned(),
);

let related = error
.to_diagnostic()
.related_information
.expect("secondary annotations should map to related information");
assert_eq!(related.len(), 2);
assert_eq!(related[0].message, "has type `int | str`");
assert_eq!(related[1].message, "has type `int`");
assert_eq!(
related[0].location.uri, related[1].location.uri,
"annotations in the same file share one uri"
);
assert!(
related[0].location.uri.as_str().ends_with("/test.py"),
"uri should point at the error's file, got {}",
related[0].location.uri
);
// The first annotation starts where the primary error does (byte 0).
assert_eq!(
related[0].location.range.start,
error.to_diagnostic().range.start
);
}

#[test]
fn test_to_diagnostic_without_annotations_has_no_related_information() {
let module_info = Module::new(
ModuleName::from_str("test"),
ModulePath::filesystem(PathBuf::from("/test.py")),
Arc::new("def f(x: int) -> str:\n return x".to_owned()),
);
let error = Error::new(
module_info,
TextRange::new(TextSize::new(26), TextSize::new(34)),
"bad return".to_owned(),
Vec::new(),
ErrorKind::BadReturn,
);
assert!(error.to_diagnostic().related_information.is_none());
}

/// Integration test: verify that binary operator errors from the type checker
/// produce secondary annotations labeling both operands with their types.
#[test]
Expand Down
27 changes: 22 additions & 5 deletions pyrefly/lib/test/lsp/lsp_interaction/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -638,10 +638,16 @@ fn test_did_change_workspace_folder() {
interaction.shutdown().expect("Failed to shutdown");
}

fn get_diagnostics_result() -> serde_json::Value {
fn get_diagnostics_result(file_uri: &Url) -> serde_json::Value {
let uri = file_uri.as_str();
json!({"items": [
{"code":"unsupported-operation","codeDescription":{"href":"https://pyrefly.org/en/docs/error-kinds/#unsupported-operation"},"message":"`+` is not supported between `Literal[1]` and `Literal['']`\n Argument `Literal['']` is not assignable to parameter `value` with type `int` in function `int.__add__`",
"range":{"end":{"character":6,"line":5},"start":{"character":0,"line":5}},"severity":1,"source":"Pyrefly"}],"kind":"full"
"range":{"end":{"character":6,"line":5},"start":{"character":0,"line":5}},
"relatedInformation":[
{"location":{"range":{"end":{"character":1,"line":5},"start":{"character":0,"line":5}},"uri":uri},"message":"has type `Literal[1]`"},
{"location":{"range":{"end":{"character":6,"line":5},"start":{"character":4,"line":5}},"uri":uri},"message":"has type `Literal['']`"}
],
"severity":1,"source":"Pyrefly"}],"kind":"full"
})
}

Expand Down Expand Up @@ -952,10 +958,11 @@ fn test_diagnostics_default_workspace_with_config() {

interaction.client.did_open("type_errors.py");

let type_errors_uri = Url::from_file_path(root.join("type_errors.py")).unwrap();
interaction
.client
.diagnostic("type_errors.py")
.expect_response(get_diagnostics_result())
.expect_response(get_diagnostics_result(&type_errors_uri))
.expect("Failed to receive expected response");

interaction.client.did_change_configuration();
Expand Down Expand Up @@ -1040,10 +1047,15 @@ fn test_diagnostics_file_not_in_includes() {
.did_open("diagnostics_file_not_in_includes/type_errors_include.py");

// prove that it works for a project included
let include_uri = Url::from_file_path(
root.path()
.join("diagnostics_file_not_in_includes/type_errors_include.py"),
)
.unwrap();
interaction
.client
.diagnostic("diagnostics_file_not_in_includes/type_errors_include.py")
.expect_response(get_diagnostics_result())
.expect_response(get_diagnostics_result(&include_uri))
.expect("Failed to receive expected response");

// prove that it ignores a file not in project includes
Expand Down Expand Up @@ -1078,10 +1090,15 @@ fn test_diagnostics_file_in_excludes() {
.did_open("diagnostics_file_in_excludes/type_errors_include.py");

// prove that it works for a project included
let include_uri = Url::from_file_path(
root.path()
.join("diagnostics_file_in_excludes/type_errors_include.py"),
)
.unwrap();
interaction
.client
.diagnostic("diagnostics_file_in_excludes/type_errors_include.py")
.expect_response(get_diagnostics_result())
.expect_response(get_diagnostics_result(&include_uri))
.expect("Failed to receive expected response");

// prove that it ignores a file not in project includes
Expand Down
Loading
Loading