diff --git a/crates/aiken-lang/src/ast.rs b/crates/aiken-lang/src/ast.rs index 61f93e6a..e820de51 100644 --- a/crates/aiken-lang/src/ast.rs +++ b/crates/aiken-lang/src/ast.rs @@ -78,6 +78,34 @@ impl TypedModule { .find_map(|definition| definition.find_node(byte_index)) } + pub fn has_definition(&self, name: &str) -> bool { + self.definitions.iter().any(|def| match def { + Definition::Fn(f) => f.public && f.name == name, + Definition::TypeAlias(alias) => alias.public && alias.alias == name, + Definition::ModuleConstant(cst) => cst.public && cst.name == name, + Definition::DataType(t) => t.public && t.name == name, + Definition::Use(_) => false, + Definition::Test(_) => false, + Definition::Validator(_) => false, + }) + } + + pub fn has_constructor(&self, name: &str) -> bool { + self.definitions.iter().any(|def| match def { + Definition::DataType(t) if t.public && !t.opaque => t + .constructors + .iter() + .any(|constructor| constructor.name == name), + Definition::DataType(_) => false, + Definition::Fn(_) => false, + Definition::TypeAlias(_) => false, + Definition::ModuleConstant(_) => false, + Definition::Use(_) => false, + Definition::Test(_) => false, + Definition::Validator(_) => false, + }) + } + pub fn validate_module_name(&self) -> Result<(), Error> { if self.name == "aiken" || self.name == "aiken/builtin" { return Err(Error::ReservedModuleName { diff --git a/crates/aiken-lang/src/error/mod.rs b/crates/aiken-lang/src/error/mod.rs new file mode 100644 index 00000000..30b902c3 --- /dev/null +++ b/crates/aiken-lang/src/error/mod.rs @@ -0,0 +1,3 @@ +pub trait ExtraData { + fn extra_data(&self) -> Option; +} diff --git a/crates/aiken-lang/src/lib.rs b/crates/aiken-lang/src/lib.rs index 3b4eb8de..3e7dc7cc 100644 --- a/crates/aiken-lang/src/lib.rs +++ b/crates/aiken-lang/src/lib.rs @@ -5,6 +5,7 @@ use std::sync::{ pub mod ast; pub mod builtins; +pub mod error; pub mod expr; pub mod format; pub mod gen_uplc; diff --git a/crates/aiken-lang/src/tipo/environment.rs b/crates/aiken-lang/src/tipo/environment.rs index b95a5a0b..954ebfdc 100644 --- a/crates/aiken-lang/src/tipo/environment.rs +++ b/crates/aiken-lang/src/tipo/environment.rs @@ -309,11 +309,14 @@ impl<'a> Environment<'a> { location: Span, ) -> Result<&ValueConstructor, Error> { match module { - None => self.scope.get(name).ok_or_else(|| Error::UnknownVariable { - location, - name: name.to_string(), - variables: self.local_value_names(), - }), + None => self + .scope + .get(name) + .ok_or_else(|| Error::UnknownTypeConstructor { + location, + name: name.to_string(), + constructors: self.local_constructor_names(), + }), Some(m) => { let (_, module) = @@ -577,6 +580,14 @@ impl<'a> Environment<'a> { .collect() } + pub fn local_constructor_names(&self) -> Vec { + self.scope + .keys() + .filter(|&t| t.chars().next().unwrap_or_default().is_uppercase()) + .map(|t| t.to_string()) + .collect() + } + fn make_type_vars( &mut self, args: &[String], diff --git a/crates/aiken-lang/src/tipo/error.rs b/crates/aiken-lang/src/tipo/error.rs index 396da272..2ce8b768 100644 --- a/crates/aiken-lang/src/tipo/error.rs +++ b/crates/aiken-lang/src/tipo/error.rs @@ -1,4 +1,5 @@ use super::Type; +use crate::error::ExtraData; use crate::{ ast::{Annotation, BinOp, CallArg, LogicalOpChainKind, Span, UntypedPattern}, expr::{self, UntypedExpr}, @@ -798,7 +799,7 @@ Perhaps, try the following: suggest_neighbor(name, constructors.iter(), "Did you forget to import it?") ))] UnknownTypeConstructor { - #[label] + #[label("unknown constructor")] location: Span, name: String, constructors: Vec, @@ -922,6 +923,64 @@ The best thing to do from here is to remove it."#))] }, } +impl ExtraData for Error { + fn extra_data(&self) -> Option { + match self { + Error::CastDataNoAnn { .. } + | Error::CouldNotUnify { .. } + | Error::CyclicTypeDefinitions { .. } + | Error::DuplicateArgument { .. } + | Error::DuplicateConstName { .. } + | Error::DuplicateField { .. } + | Error::DuplicateImport { .. } + | Error::DuplicateName { .. } + | Error::DuplicateTypeName { .. } + | Error::DuplicateVarInPattern { .. } + | Error::ExtraVarInAlternativePattern { .. } + | Error::FunctionTypeInData { .. } + | Error::ImplicitlyDiscardedExpression { .. } + | Error::IncorrectFieldsArity { .. } + | Error::IncorrectFunctionCallArity { .. } + | Error::IncorrectPatternArity { .. } + | Error::IncorrectTupleArity { .. } + | Error::IncorrectTypeArity { .. } + | Error::IncorrectValidatorArity { .. } + | Error::KeywordInModuleName { .. } + | Error::LastExpressionIsAssignment { .. } + | Error::LogicalOpChainMissingExpr { .. } + | Error::MissingVarInAlternativePattern { .. } + | Error::MultiValidatorEqualArgs { .. } + | Error::NonLocalClauseGuardVariable { .. } + | Error::NotATuple { .. } + | Error::NotExhaustivePatternMatch { .. } + | Error::NotFn { .. } + | Error::PositionalArgumentAfterLabeled { .. } + | Error::PrivateTypeLeak { .. } + | Error::RecordAccessUnknownType { .. } + | Error::RecordUpdateInvalidConstructor { .. } + | Error::RecursiveType { .. } + | Error::RedundantMatchClause { .. } + | Error::TupleIndexOutOfBound { .. } + | Error::UnexpectedLabeledArg { .. } + | Error::UnexpectedLabeledArgInPattern { .. } + | Error::UnknownLabels { .. } + | Error::UnknownModuleField { .. } + | Error::UnknownModuleType { .. } + | Error::UnknownModuleValue { .. } + | Error::UnknownRecordField { .. } + | Error::UnnecessarySpreadOperator { .. } + | Error::UpdateMultiConstructorType { .. } + | Error::ValidatorImported { .. } + | Error::ValidatorMustReturnBool { .. } => None, + + Error::UnknownType { name, .. } + | Error::UnknownTypeConstructor { name, .. } + | Error::UnknownVariable { name, .. } + | Error::UnknownModule { name, .. } => Some(name.clone()), + } + } +} + impl Error { pub fn call_situation(mut self) -> Self { if let Error::UnknownRecordField { @@ -1522,6 +1581,30 @@ pub enum Warning { }, } +impl ExtraData for Warning { + fn extra_data(&self) -> Option { + match self { + Warning::AllFieldsRecordUpdate { .. } + | Warning::ImplicitlyDiscardedResult { .. } + | Warning::NoFieldsRecordUpdate { .. } + | Warning::PubInValidatorModule { .. } + | Warning::SingleConstructorExpect { .. } + | Warning::SingleWhenClause { .. } + | Warning::Todo { .. } + | Warning::UnexpectedTypeHole { .. } + | Warning::UnusedConstructor { .. } + | Warning::UnusedImportedModule { .. } + | Warning::UnusedImportedValue { .. } + | Warning::UnusedPrivateFunction { .. } + | Warning::UnusedPrivateModuleConstant { .. } + | Warning::UnusedType { .. } + | Warning::UnusedVariable { .. } + | Warning::Utf8ByteArrayIsValidHexString { .. } + | Warning::ValidatorInLibraryModule { .. } => None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UnifyErrorSituation { /// Clauses in a case expression were found to return different types. diff --git a/crates/aiken-lsp/src/edits.rs b/crates/aiken-lsp/src/edits.rs new file mode 100644 index 00000000..1ccb4a0b --- /dev/null +++ b/crates/aiken-lsp/src/edits.rs @@ -0,0 +1,206 @@ +use crate::{line_numbers::LineNumbers, utils::span_to_lsp_range}; +use aiken_lang::ast::{Definition, ModuleKind, Span, UntypedDefinition, Use}; +use aiken_project::module::CheckedModule; +use itertools::Itertools; +use std::fs; + +/// A freshly parsed module alongside its line numbers. +pub struct ParsedDocument { + definitions: Vec, + line_numbers: LineNumbers, +} + +pub type AnnotatedEdit = (String, lsp_types::TextEdit); + +/// Parse the target document as an 'UntypedModule' alongside its line numbers. This is useful in +/// case we need to manipulate the AST for a quickfix. +pub fn parse_document(document: &lsp_types::TextDocumentIdentifier) -> Option { + let file_path = document + .uri + .to_file_path() + .expect("invalid text document uri?"); + + let source_code = fs::read_to_string(file_path).ok()?; + + let line_numbers = LineNumbers::new(&source_code); + + // NOTE: The 'ModuleKind' second argument doesn't matter. This is just added to the final + // object but has no influence on the parsing. + let (untyped_module, _) = aiken_lang::parser::module(&source_code, ModuleKind::Lib).ok()?; + + Some(ParsedDocument { + definitions: untyped_module.definitions, + line_numbers, + }) +} + +/// Insert some text at the given location. +fn insert_text(at: usize, line_numbers: &LineNumbers, new_text: String) -> lsp_types::TextEdit { + let range = span_to_lsp_range(Span { start: at, end: at }, line_numbers); + lsp_types::TextEdit { range, new_text } +} + +/// Find a suitable location (Span) in the import list. The boolean in the answer indicates +/// whether the import is a newline or not. It is set to 'false' when adding a qualified import +/// to an existing list. +impl ParsedDocument { + pub fn import( + &self, + import: &CheckedModule, + unqualified: Option<&str>, + ) -> Option { + let import_path = import.name.split('/').collect_vec(); + + let mut last_import = None; + + for def in self.definitions.iter() { + match def { + Definition::Use(Use { + location, + module: existing_module, + unqualified: unqualified_list, + .. + }) => { + last_import = Some(*location); + + if import_path != existing_module.as_slice() { + continue; + } + + match unqualified { + // There's already a matching qualified import, so we have nothing to do. + None => return None, + Some(unqualified) => { + let mut last_unqualified = None; + + // Insert lexicographically, assuming unqualified imports are already + // ordered. If they are not, it doesn't really matter where we insert + // anyway. + for existing_unqualified in unqualified_list { + last_unqualified = Some(existing_unqualified.location); + + let existing_name = existing_unqualified + .as_name + .as_ref() + .unwrap_or(&existing_unqualified.name); + + // The unqualified import already exist, nothing to do. + if unqualified == existing_name { + return None; + // Current import is lexicographically greater, we can insert before + } else if unqualified < existing_name.as_str() { + return Some(self.insert_qualified_before( + import, + unqualified, + existing_unqualified.location, + )); + } else { + continue; + } + } + + return match last_unqualified { + // Only happens if 'unqualified_list' is empty, in which case, we + // simply create a new unqualified list of import. + None => { + Some(self.add_new_qualified(import, unqualified, *location)) + } + // Happens if the new qualified import is lexicographically after + // all existing ones. + Some(location) => { + Some(self.insert_qualified_after(import, unqualified, location)) + } + }; + } + } + } + _ => continue, + } + } + + // If the search above didn't lead to anything, we simply insert the import either: + // + // (a) After the last import statement if any; + // (b) As the first statement in the module. + Some(self.add_new_import_line(import, unqualified, last_import)) + } + + fn insert_qualified_before( + &self, + import: &CheckedModule, + unqualified: &str, + location: Span, + ) -> AnnotatedEdit { + let title = format!("Use '{}' from {}", unqualified, import.name); + ( + title, + insert_text( + location.start, + &self.line_numbers, + format!("{}, ", unqualified), + ), + ) + } + + fn insert_qualified_after( + &self, + import: &CheckedModule, + unqualified: &str, + location: Span, + ) -> AnnotatedEdit { + let title = format!("Use '{}' from {}", unqualified, import.name); + ( + title, + insert_text( + location.end, + &self.line_numbers, + format!(", {}", unqualified), + ), + ) + } + + fn add_new_qualified( + &self, + import: &CheckedModule, + unqualified: &str, + location: Span, + ) -> AnnotatedEdit { + let title = format!("Use '{}' from {}", unqualified, import.name); + ( + title, + insert_text( + location.end, + &self.line_numbers, + format!(".{{{}}}", unqualified), + ), + ) + } + + fn add_new_import_line( + &self, + import: &CheckedModule, + unqualified: Option<&str>, + location: Option, + ) -> AnnotatedEdit { + let import_line = format!( + "use {}{}", + import.name, + match unqualified { + Some(unqualified) => format!(".{{{}}}", unqualified), + None => String::new(), + } + ); + + let title = format!("Add new import line: {import_line}"); + + ( + title, + match location { + None => insert_text(0, &self.line_numbers, format!("{import_line}\n")), + Some(Span { end, .. }) => { + insert_text(end, &self.line_numbers, format!("\n{import_line}")) + } + }, + ) + } +} diff --git a/crates/aiken-lsp/src/lib.rs b/crates/aiken-lsp/src/lib.rs index 310fe37e..b627081f 100644 --- a/crates/aiken-lsp/src/lib.rs +++ b/crates/aiken-lsp/src/lib.rs @@ -5,8 +5,10 @@ use lsp_server::Connection; use std::env; mod cast; +mod edits; pub mod error; mod line_numbers; +mod quickfix; pub mod server; mod utils; @@ -60,6 +62,7 @@ fn capabilities() -> lsp_types::ServerCapabilities { // work_done_progress: None, // }, // }), + code_action_provider: Some(lsp_types::CodeActionProviderCapability::Simple(true)), document_formatting_provider: Some(lsp_types::OneOf::Left(true)), definition_provider: Some(lsp_types::OneOf::Left(true)), hover_provider: Some(lsp_types::HoverProviderCapability::Simple(true)), diff --git a/crates/aiken-lsp/src/quickfix.rs b/crates/aiken-lsp/src/quickfix.rs new file mode 100644 index 00000000..a5c6184d --- /dev/null +++ b/crates/aiken-lsp/src/quickfix.rs @@ -0,0 +1,146 @@ +use crate::{ + edits::{self, AnnotatedEdit, ParsedDocument}, + server::lsp_project::LspProject, +}; +use std::collections::HashMap; + +const UNKNOWN_VARIABLE: &str = "aiken::check::unknown::variable"; +const UNKNOWN_TYPE: &str = "aiken::check::unknown::type"; +const UNKNOWN_CONSTRUCTOR: &str = "aiken::check::unknown::type_constructor"; +const UNKNOWN_MODULE: &str = "aiken::check::unknown::module"; + +/// Errors for which we can provide quickfixes +#[allow(clippy::enum_variant_names)] +pub enum Quickfix { + UnknownIdentifier, + UnknownModule, + UnknownConstructor, +} + +fn match_code(diagnostic: &lsp_types::Diagnostic, expected: &str) -> bool { + diagnostic.code == Some(lsp_types::NumberOrString::String(expected.to_string())) +} + +/// Assert whether a diagnostic can be automatically fixed. Note that diagnostics often comes in +/// two severities, an error and hint; so we must be careful only addressing errors. +pub fn assert(diagnostic: &lsp_types::Diagnostic) -> Option { + let is_error = diagnostic.severity == Some(lsp_types::DiagnosticSeverity::ERROR); + + if !is_error { + return None; + } + + if match_code(diagnostic, UNKNOWN_VARIABLE) { + return Some(Quickfix::UnknownIdentifier); + } + + if match_code(diagnostic, UNKNOWN_TYPE) { + return Some(Quickfix::UnknownIdentifier); + } + + if match_code(diagnostic, UNKNOWN_CONSTRUCTOR) { + return Some(Quickfix::UnknownConstructor); + } + + if match_code(diagnostic, UNKNOWN_MODULE) { + return Some(Quickfix::UnknownModule); + } + + None +} + +pub fn quickfix( + compiler: &LspProject, + text_document: &lsp_types::TextDocumentIdentifier, + diagnostic: &lsp_types::Diagnostic, + quickfix: &Quickfix, +) -> Vec { + let mut actions = Vec::new(); + + if let Some(ref parsed_document) = edits::parse_document(text_document) { + if let Some(serde_json::Value::String(ref data)) = diagnostic.data { + let edits = match quickfix { + Quickfix::UnknownIdentifier => unknown_identifier(compiler, parsed_document, data), + Quickfix::UnknownModule => unknown_module(compiler, parsed_document, data), + Quickfix::UnknownConstructor => { + unknown_constructor(compiler, parsed_document, data) + } + }; + + for (title, edit) in edits.into_iter() { + let mut changes = HashMap::new(); + changes.insert(text_document.uri.clone(), vec![edit]); + actions.push(lsp_types::CodeAction { + title, + kind: Some(lsp_types::CodeActionKind::QUICKFIX), + diagnostics: Some(vec![diagnostic.clone()]), + is_preferred: Some(true), + disabled: None, + data: None, + command: None, + edit: Some(lsp_types::WorkspaceEdit { + changes: Some(changes), + document_changes: None, + change_annotations: None, + }), + }); + } + } + } + + actions +} + +fn unknown_identifier( + compiler: &LspProject, + parsed_document: &ParsedDocument, + var_name: &str, +) -> Vec { + let mut edits = Vec::new(); + + for module in compiler.project.modules() { + if module.ast.has_definition(var_name) { + if let Some(edit) = parsed_document.import(&module, Some(var_name)) { + edits.push(edit) + } + } + } + + edits +} + +fn unknown_constructor( + compiler: &LspProject, + parsed_document: &ParsedDocument, + constructor_name: &str, +) -> Vec { + let mut edits = Vec::new(); + + for module in compiler.project.modules() { + if module.ast.has_constructor(constructor_name) { + if let Some(edit) = parsed_document.import(&module, Some(constructor_name)) { + edits.push(edit) + } + } + } + + edits +} + +fn unknown_module( + compiler: &LspProject, + parsed_document: &ParsedDocument, + module_name: &str, +) -> Vec { + let mut edits = Vec::new(); + + for module in compiler.project.modules() { + if module.name.ends_with(module_name) { + if let Some(edit) = parsed_document.import(&module, None) { + edits.push(edit); + } + } + } + + edits +} diff --git a/crates/aiken-lsp/src/server.rs b/crates/aiken-lsp/src/server.rs index ff42e881..19e9942a 100644 --- a/crates/aiken-lsp/src/server.rs +++ b/crates/aiken-lsp/src/server.rs @@ -6,6 +6,7 @@ use std::{ use aiken_lang::{ ast::{Definition, Located, ModuleKind, Span, Use}, + error::ExtraData, parser, tipo::pretty::Printer, }; @@ -23,7 +24,8 @@ use lsp_types::{ Notification, Progress, PublishDiagnostics, ShowMessage, }, request::{ - Completion, Formatting, GotoDefinition, HoverRequest, Request, WorkDoneProgressCreate, + CodeActionRequest, Completion, Formatting, GotoDefinition, HoverRequest, Request, + WorkDoneProgressCreate, }, DocumentFormattingParams, InitializeParams, TextEdit, }; @@ -33,6 +35,7 @@ use crate::{ cast::{cast_notification, cast_request}, error::Error as ServerError, line_numbers::LineNumbers, + quickfix, utils::{ path_to_uri, span_to_lsp_range, text_edit_replace, uri_to_module_name, COMPILING_PROGRESS_TOKEN, CREATE_COMPILING_PROGRESS_TOKEN, @@ -41,7 +44,7 @@ use crate::{ use self::lsp_project::LspProject; -mod lsp_project; +pub mod lsp_project; pub mod telemetry; #[allow(dead_code)] @@ -288,6 +291,7 @@ impl Server { } } } + HoverRequest::METHOD => { let params = cast_request::(request)?; @@ -324,6 +328,33 @@ impl Server { }) } + CodeActionRequest::METHOD => { + let mut actions = Vec::new(); + + if let Some(ref compiler) = self.compiler { + let params = cast_request::(request) + .expect("cast code action request"); + + for diagnostic in params.context.diagnostics.iter() { + if let Some(strategy) = quickfix::assert(diagnostic) { + let quickfixes = quickfix::quickfix( + compiler, + ¶ms.text_document, + diagnostic, + &strategy, + ); + actions.extend(quickfixes); + } + } + } + + Ok(lsp_server::Response { + id, + error: None, + result: Some(serde_json::to_value(actions)?), + }) + } + unsupported => Err(ServerError::UnsupportedLspRequest { request: unsupported.to_string(), }), @@ -445,7 +476,6 @@ impl Server { fn module_for_uri(&self, uri: &url::Url) -> Option<&CheckedModule> { self.compiler.as_ref().and_then(|compiler| { let module_name = uri_to_module_name(uri, &self.root).expect("uri to module name"); - compiler.modules.get(&module_name) }) } @@ -591,7 +621,7 @@ impl Server { /// the `showMessage` notification instead. fn process_diagnostic(&mut self, error: E) -> Result<(), ServerError> where - E: Diagnostic + GetSource, + E: Diagnostic + GetSource + ExtraData, { let (severity, typ) = match error.severity() { Some(severity) => match severity { @@ -642,7 +672,7 @@ impl Server { message, related_information: None, tags: None, - data: None, + data: error.extra_data().map(serde_json::Value::String), }; #[cfg(not(target_os = "windows"))] diff --git a/crates/aiken-lsp/src/server/lsp_project.rs b/crates/aiken-lsp/src/server/lsp_project.rs index e581f86b..4c58a79d 100644 --- a/crates/aiken-lsp/src/server/lsp_project.rs +++ b/crates/aiken-lsp/src/server/lsp_project.rs @@ -38,8 +38,6 @@ impl LspProject { self.project.restore(checkpoint); - result?; - let modules = self.project.modules(); for mut module in modules.into_iter() { @@ -61,6 +59,8 @@ impl LspProject { self.modules.insert(module.name.to_string(), module); } + result?; + Ok(()) } } diff --git a/crates/aiken-project/src/error.rs b/crates/aiken-project/src/error.rs index f657c83b..0dcd2653 100644 --- a/crates/aiken-project/src/error.rs +++ b/crates/aiken-project/src/error.rs @@ -4,6 +4,7 @@ use crate::{ }; use aiken_lang::{ ast::{self, BinOp, Span}, + error::ExtraData, parser::error::ParseError, tipo, }; @@ -172,6 +173,34 @@ impl From for Vec { } } +impl ExtraData for Error { + fn extra_data(&self) -> Option { + match self { + Error::DuplicateModule { .. } => None, + Error::FileIo { .. } => None, + Error::Format { .. } => None, + Error::StandardIo { .. } => None, + Error::Blueprint { .. } => None, + Error::MissingManifest { .. } => None, + Error::TomlLoading { .. } => None, + Error::ImportCycle { .. } => None, + Error::Parse { .. } => None, + Error::Type { error, .. } => error.extra_data(), + Error::TestFailure { .. } => None, + Error::Http { .. } => None, + Error::ZipExtract { .. } => None, + Error::JoinError { .. } => None, + Error::UnknownPackageVersion { .. } => None, + Error::UnableToResolvePackage { .. } => None, + Error::Json { .. } => None, + Error::MalformedStakeAddress { .. } => None, + Error::NoValidatorNotFound { .. } => None, + Error::MoreThanOneValidatorFound { .. } => None, + Error::Module { .. } => None, + } + } +} + pub trait GetSource { fn path(&self) -> Option; fn src(&self) -> Option; @@ -481,6 +510,16 @@ pub enum Warning { DependencyAlreadyExists { name: PackageName }, } +impl ExtraData for Warning { + fn extra_data(&self) -> Option { + match self { + Warning::NoValidators { .. } => None, + Warning::DependencyAlreadyExists { .. } => None, + Warning::Type { warning, .. } => warning.extra_data(), + } + } +} + impl GetSource for Warning { fn path(&self) -> Option { match self {