Skip to content

Commit 332f6ed

Browse files
committed
Add create unknown module code action
1 parent 9a7f74f commit 332f6ed

8 files changed

+462
-48
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,15 @@
324324

325325
([Surya Rose](https://github.com/GearsDatapacks))
326326

327+
- The language server now offers a code action to create unknown modules
328+
when an import is added for a module that doesn't exist.
329+
330+
For example, if `import wobble/woo` is added to `src/wiggle.gleam`,
331+
then a code action to create `src/wobble/woo.gleam` will be presented
332+
when triggered over `import wobble/woo`.
333+
334+
([Cory Forsstrom](https://github.com/tarkah))
335+
327336
### Formatter
328337

329338
- The formatter now removes needless multiple negations that are safe to remove.

compiler-core/src/language_server/code_action.rs

Lines changed: 125 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,29 @@ use crate::{
1010
TypedExpr, TypedModuleConstant, TypedPattern, TypedPipelineAssignment,
1111
TypedRecordConstructor, TypedStatement, TypedUse, visit::Visit as _,
1212
},
13-
build::{Located, Module},
13+
build::{Located, Module, Origin},
1414
config::PackageConfig,
1515
exhaustiveness::CompiledCase,
1616
io::{BeamCompiler, CommandExecutor, FileSystemReader, FileSystemWriter},
17-
language_server::{edits, reference::FindVariableReferences},
17+
language_server::{edits, lsp_range_to_src_span, reference::FindVariableReferences},
1818
line_numbers::LineNumbers,
1919
parse::{extra::ModuleExtra, lexer::str_to_keyword},
20+
paths::ProjectPaths,
2021
strings::to_snake_case,
2122
type_::{
22-
self, FieldMap, ModuleValueConstructor, Type, TypeVar, TypedCallArg, ValueConstructor,
23+
self, Error as TypeError, FieldMap, ModuleValueConstructor, Type, TypeVar, TypedCallArg,
24+
ValueConstructor,
2325
error::{ModuleSuggestion, VariableDeclaration, VariableOrigin},
2426
printer::Printer,
2527
},
2628
};
2729
use ecow::{EcoString, eco_format};
2830
use im::HashMap;
2931
use itertools::Itertools;
30-
use lsp_types::{CodeAction, CodeActionKind, CodeActionParams, Position, Range, TextEdit, Url};
32+
use lsp_types::{
33+
CodeAction, CodeActionKind, CodeActionParams, CreateFile, CreateFileOptions,
34+
DocumentChangeOperation, DocumentChanges, Position, Range, ResourceOp, TextEdit, Url,
35+
};
3136
use vec1::{Vec1, vec1};
3237

3338
use super::{
@@ -46,7 +51,7 @@ pub struct CodeActionBuilder {
4651
}
4752

4853
impl CodeActionBuilder {
49-
pub fn new(title: &str) -> Self {
54+
pub fn new(title: impl ToString) -> Self {
5055
Self {
5156
action: CodeAction {
5257
title: title.to_string(),
@@ -76,6 +81,15 @@ impl CodeActionBuilder {
7681
self
7782
}
7883

84+
pub fn document_changes(mut self, changes: DocumentChanges) -> Self {
85+
let mut edit = self.action.edit.take().unwrap_or_default();
86+
87+
edit.document_changes = Some(changes);
88+
89+
self.action.edit = Some(edit);
90+
self
91+
}
92+
7993
pub fn preferred(mut self, is_preferred: bool) -> Self {
8094
self.action.is_preferred = Some(is_preferred);
8195
self
@@ -1571,7 +1585,7 @@ impl<'a> QualifiedToUnqualifiedImportSecondPass<'a> {
15711585
}
15721586
self.edit_import();
15731587
let mut action = Vec::with_capacity(1);
1574-
CodeActionBuilder::new(&format!(
1588+
CodeActionBuilder::new(format!(
15751589
"Unqualify {}.{}",
15761590
self.qualified_constructor.used_name, self.qualified_constructor.constructor
15771591
))
@@ -1959,7 +1973,7 @@ impl<'a> UnqualifiedToQualifiedImportSecondPass<'a> {
19591973
constructor,
19601974
..
19611975
} = self.unqualified_constructor;
1962-
CodeActionBuilder::new(&format!(
1976+
CodeActionBuilder::new(format!(
19631977
"Qualify {} as {}.{}",
19641978
constructor.used_name(),
19651979
module_name,
@@ -7081,7 +7095,7 @@ impl<'a> FixBinaryOperation<'a> {
70817095
self.edits.replace(location, replacement.name().into());
70827096

70837097
let mut action = Vec::with_capacity(1);
7084-
CodeActionBuilder::new(&format!("Use `{}`", replacement.name()))
7098+
CodeActionBuilder::new(format!("Use `{}`", replacement.name()))
70857099
.kind(CodeActionKind::REFACTOR_REWRITE)
70867100
.changes(self.params.text_document.uri.clone(), self.edits.edits)
70877101
.preferred(true)
@@ -7164,7 +7178,7 @@ impl<'a> FixTruncatedBitArraySegment<'a> {
71647178
.replace(truncation.value_location, replacement.clone());
71657179

71667180
let mut action = Vec::with_capacity(1);
7167-
CodeActionBuilder::new(&format!("Replace with `{replacement}`"))
7181+
CodeActionBuilder::new(format!("Replace with `{replacement}`"))
71687182
.kind(CodeActionKind::REFACTOR_REWRITE)
71697183
.changes(self.params.text_document.uri.clone(), self.edits.edits)
71707184
.preferred(true)
@@ -8006,3 +8020,105 @@ fn single_expression(expression: &TypedExpr) -> Option<&TypedExpr> {
80068020
expression => Some(expression),
80078021
}
80088022
}
8023+
8024+
/// Code action to create unknown modules when an import is added for a
8025+
/// module that doesn't exist.
8026+
///
8027+
/// For example, if `import wobble/woo` is added to `src/wiggle.gleam`,
8028+
/// then a code action to create `src/wobble/woo.gleam` will be presented
8029+
/// when triggered over `import wobble/woo`.
8030+
pub struct CreateUnknownModule<'a> {
8031+
module: &'a Module,
8032+
lines: &'a LineNumbers,
8033+
params: &'a CodeActionParams,
8034+
paths: &'a ProjectPaths,
8035+
error: &'a Option<Error>,
8036+
}
8037+
8038+
impl<'a> CreateUnknownModule<'a> {
8039+
pub fn new(
8040+
module: &'a Module,
8041+
lines: &'a LineNumbers,
8042+
params: &'a CodeActionParams,
8043+
paths: &'a ProjectPaths,
8044+
error: &'a Option<Error>,
8045+
) -> Self {
8046+
Self {
8047+
module,
8048+
lines,
8049+
params,
8050+
paths,
8051+
error,
8052+
}
8053+
}
8054+
8055+
pub fn code_actions(self) -> Vec<CodeAction> {
8056+
struct UnknownModule<'a> {
8057+
name: &'a EcoString,
8058+
location: &'a SrcSpan,
8059+
}
8060+
8061+
let mut actions = vec![];
8062+
8063+
// This code action can be derived from UnknownModule type errors. If those
8064+
// errors don't exist, there are no actions to add.
8065+
let Some(Error::Type { errors, .. }) = self.error else {
8066+
return actions;
8067+
};
8068+
8069+
// Span of the code action so we can check if it exists within the span of
8070+
// the UnkownModule type error
8071+
let code_action_span = lsp_range_to_src_span(self.params.range, self.lines);
8072+
8073+
// Origin directory we can build the new module path from
8074+
let origin_directory = match self.module.origin {
8075+
Origin::Src => self.paths.src_directory(),
8076+
Origin::Test => self.paths.test_directory(),
8077+
Origin::Dev => self.paths.dev_directory(),
8078+
};
8079+
8080+
// Filter for any UnknownModule type errors
8081+
let unknown_modules = errors.iter().filter_map(|error| {
8082+
if let TypeError::UnknownModule { name, location, .. } = error {
8083+
return Some(UnknownModule { name, location });
8084+
}
8085+
8086+
None
8087+
});
8088+
8089+
// For each UnknownModule type error, check to see if it contains the
8090+
// incoming code action & if so, add a document change to create the module
8091+
for unknown_module in unknown_modules {
8092+
// Was this code action triggered within the UnknownModule error?
8093+
let error_contains_action = unknown_module.location.contains(code_action_span.start)
8094+
&& unknown_module.location.contains(code_action_span.end);
8095+
8096+
if !error_contains_action {
8097+
continue;
8098+
}
8099+
8100+
let uri = url_from_path(&format!("{origin_directory}/{}.gleam", unknown_module.name))
8101+
.expect("origin directory is absolute");
8102+
8103+
CodeActionBuilder::new(format!(
8104+
"Create {}/{}.gleam",
8105+
self.module.origin.folder_name(),
8106+
unknown_module.name
8107+
))
8108+
.kind(CodeActionKind::QUICKFIX)
8109+
.document_changes(DocumentChanges::Operations(vec![
8110+
DocumentChangeOperation::Op(ResourceOp::Create(CreateFile {
8111+
uri,
8112+
options: Some(CreateFileOptions {
8113+
overwrite: Some(false),
8114+
ignore_if_exists: Some(true),
8115+
}),
8116+
annotation_id: None,
8117+
})),
8118+
]))
8119+
.push_to(&mut actions);
8120+
}
8121+
8122+
actions
8123+
}
8124+
}

compiler-core/src/language_server/engine.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ use super::{
4242
DownloadDependencies, MakeLocker,
4343
code_action::{
4444
AddAnnotations, CodeActionBuilder, ConvertFromUse, ConvertToFunctionCall, ConvertToPipe,
45-
ConvertToUse, ExpandFunctionCapture, ExtractConstant, ExtractVariable,
45+
ConvertToUse, CreateUnknownModule, ExpandFunctionCapture, ExtractConstant, ExtractVariable,
4646
FillInMissingLabelledArgs, FillUnusedFields, FixBinaryOperation,
4747
FixTruncatedBitArraySegment, GenerateDynamicDecoder, GenerateFunction, GenerateJsonEncoder,
4848
GenerateVariant, InlineVariable, InterpolateString, LetAssertToCase, PatternMatchOnValue,
@@ -454,6 +454,10 @@ where
454454
)
455455
.code_actions();
456456
AddAnnotations::new(module, &lines, &params).code_action(&mut actions);
457+
actions.extend(
458+
CreateUnknownModule::new(module, &lines, &params, &this.paths, &this.error)
459+
.code_actions(),
460+
);
457461
Ok(if actions.is_empty() {
458462
None
459463
} else {
@@ -1540,7 +1544,7 @@ fn code_action_fix_names(
15401544
new_text: correction.to_string(),
15411545
};
15421546

1543-
CodeActionBuilder::new(&format!("Rename to {correction}"))
1547+
CodeActionBuilder::new(format!("Rename to {correction}"))
15441548
.kind(lsp_types::CodeActionKind::QUICKFIX)
15451549
.changes(uri.clone(), vec![edit])
15461550
.preferred(true)

compiler-core/src/language_server/tests.rs

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use lsp_types::{Position, TextDocumentIdentifier, TextDocumentPositionParams, Ur
2424

2525
use crate::{
2626
Result,
27+
build::Origin,
2728
config::PackageConfig,
2829
io::{
2930
BeamCompiler, Command, CommandExecutor, FileSystemReader, FileSystemWriter, ReadDir,
@@ -597,6 +598,18 @@ impl<'a> TestProject<'a> {
597598
TextDocumentPositionParams::new(TextDocumentIdentifier::new(url), position)
598599
}
599600

601+
pub fn build_src_path(&self, position: Position, module: &str) -> TextDocumentPositionParams {
602+
let path = Utf8PathBuf::from(if cfg!(target_family = "windows") {
603+
format!(r"\\?\C:\src\{module}.gleam")
604+
} else {
605+
format!("/src/{module}.gleam")
606+
});
607+
608+
let url = Url::from_file_path(path).unwrap();
609+
610+
TextDocumentPositionParams::new(TextDocumentIdentifier::new(url), position)
611+
}
612+
600613
pub fn build_test_path(
601614
&self,
602615
position: Position,
@@ -649,6 +662,27 @@ impl<'a> TestProject<'a> {
649662
(engine, param)
650663
}
651664

665+
pub fn positioned_with_io_in_src(
666+
&self,
667+
position: Position,
668+
module: &str,
669+
) -> (
670+
LanguageServerEngine<LanguageServerTestIO, LanguageServerTestIO>,
671+
TextDocumentPositionParams,
672+
) {
673+
let mut io = LanguageServerTestIO::new();
674+
let mut engine = self.build_engine(&mut io);
675+
676+
// Add the final module we're going to be positioning the cursor in.
677+
_ = io.src_module("app", self.src);
678+
679+
let _response = engine.compile_please();
680+
681+
let param = self.build_src_path(position, module);
682+
683+
(engine, param)
684+
}
685+
652686
pub fn positioned_with_io_in_test(
653687
&self,
654688
position: Position,
@@ -663,8 +697,7 @@ impl<'a> TestProject<'a> {
663697
// Add the final module we're going to be positioning the cursor in.
664698
_ = io.src_module("app", self.src);
665699

666-
let response = engine.compile_please();
667-
assert!(response.result.is_ok());
700+
let _response = engine.compile_please();
668701

669702
let param = self.build_test_path(position, test_name);
670703

@@ -685,8 +718,7 @@ impl<'a> TestProject<'a> {
685718
// Add the final module we're going to be positioning the cursor in.
686719
_ = io.src_module("app", self.src);
687720

688-
let response = engine.compile_please();
689-
assert!(response.result.is_ok());
721+
let _response = engine.compile_please();
690722

691723
let param = self.build_dev_path(position, test_name);
692724

@@ -695,16 +727,41 @@ impl<'a> TestProject<'a> {
695727

696728
pub fn at<T>(
697729
&self,
698-
position: Position,
730+
cursor: impl Into<Cursor<'a>>,
699731
executor: impl FnOnce(
700732
&mut LanguageServerEngine<LanguageServerTestIO, LanguageServerTestIO>,
701733
TextDocumentPositionParams,
702734
EcoString,
703735
) -> T,
704736
) -> T {
705-
let (mut engine, params) = self.positioned_with_io(position);
737+
let at = cursor.into();
706738

707-
executor(&mut engine, params, self.src.into())
739+
let find_module = |modules: &[(&'a str, &'a str)], module: &'a str| {
740+
modules
741+
.iter()
742+
.find_map(|(name, code)| (*name == module).then_some(*code))
743+
.expect("Module doesn't exist")
744+
};
745+
746+
let ((mut engine, params), code) = match at {
747+
Cursor::Root(position) => (self.positioned_with_io(position), self.src),
748+
Cursor::Module(origin, module, position) => match origin {
749+
Origin::Src => (
750+
self.positioned_with_io_in_src(position, module),
751+
find_module(&self.root_package_modules, module),
752+
),
753+
Origin::Test => (
754+
self.positioned_with_io_in_test(position, module),
755+
find_module(&self.test_modules, module),
756+
),
757+
Origin::Dev => (
758+
self.positioned_with_io_in_dev(position, module),
759+
find_module(&self.dev_modules, module),
760+
),
761+
},
762+
};
763+
764+
executor(&mut engine, params, code.into())
708765
}
709766
}
710767

@@ -841,3 +898,14 @@ pub fn apply_code_edit(src: &str, mut change: Vec<lsp_types::TextEdit>) -> Strin
841898

842899
result
843900
}
901+
902+
pub enum Cursor<'a> {
903+
Root(Position),
904+
Module(Origin, &'a str, Position),
905+
}
906+
907+
impl From<Position> for Cursor<'_> {
908+
fn from(value: Position) -> Self {
909+
Self::Root(value)
910+
}
911+
}

0 commit comments

Comments
 (0)