Skip to content

Commit 38738f8

Browse files
committed
adds module docs
1 parent dd8dbf1 commit 38738f8

10 files changed

Lines changed: 122 additions & 2 deletions

File tree

src/build/portable.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,7 @@ impl PModuleExports {
373373
method_own_constraints: self.method_own_constraints.iter().map(|(k, v)| {
374374
(rest_qi(k, st), v.iter().map(|s| st.sym(*s)).collect())
375375
}).collect(),
376+
module_doc: Vec::new(), // not persisted in portable format
376377
}
377378
}
378379
}

src/cst.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ pub struct Module {
5252
pub decls: Vec<Decl>,
5353
/// All comments in the module source, in order of appearance (comment, span)
5454
pub comments: Vec<(Comment, Span)>,
55+
/// Doc-comments that appear before the `module` keyword
56+
pub doc_comments: Vec<Comment>,
5557
}
5658

5759
/// Module name (potentially qualified: Data.Array)
@@ -102,6 +104,8 @@ pub enum DataMembers {
102104
pub struct ImportDecl {
103105
pub span: Span,
104106
pub module: ModuleName,
107+
/// Span of the module name in the import (for hover support)
108+
pub module_span: Span,
105109
pub imports: Option<ImportList>,
106110
pub qualified: Option<ModuleName>,
107111
}

src/lsp/handlers/hover.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,28 @@ impl Backend {
212212
if offset < import_decl.span.start || offset >= import_decl.span.end {
213213
continue;
214214
}
215+
216+
// Check if cursor is on the module name
217+
if offset >= import_decl.module_span.start && offset < import_decl.module_span.end {
218+
let module_name = interner::resolve_module_name(&import_decl.module.parts);
219+
let docs = self.get_imported_module_doc(&module_name).await;
220+
let mut markdown = format!("```purescript\nmodule {module_name}\n```");
221+
if !docs.is_empty() {
222+
markdown.push_str("\n\n---\n\n");
223+
for doc in &docs {
224+
markdown.push_str(doc.trim());
225+
markdown.push('\n');
226+
}
227+
}
228+
return Some(Hover {
229+
contents: HoverContents::Markup(MarkupContent {
230+
kind: MarkupKind::Markdown,
231+
value: markdown,
232+
}),
233+
range: None,
234+
});
235+
}
236+
215237
let items = match &import_decl.imports {
216238
Some(ImportList::Explicit(items)) | Some(ImportList::Hiding(items)) => items,
217239
None => continue,
@@ -300,6 +322,43 @@ impl Backend {
300322
.collect()
301323
}
302324

325+
async fn get_imported_module_doc(&self, module_name: &str) -> Vec<String> {
326+
// Try registry first (has module_doc from typechecking)
327+
{
328+
let module_parts: Vec<interner::Symbol> = module_name
329+
.split('.')
330+
.map(|s| interner::intern(s))
331+
.collect();
332+
let registry = self.registry.read().await;
333+
if let Some(mod_exports) = registry.lookup(&module_parts) {
334+
if !mod_exports.module_doc.is_empty() {
335+
return mod_exports.module_doc.clone();
336+
}
337+
}
338+
}
339+
340+
// Fall back to parsing the source file
341+
let target_uri = {
342+
let mf = self.module_file_map.read().await;
343+
mf.get(module_name).cloned()
344+
};
345+
let target_uri = match target_uri {
346+
Some(u) => u,
347+
None => return Vec::new(),
348+
};
349+
let target_source = match self.get_source_for_uri(&target_uri).await {
350+
Some(s) => s,
351+
None => return Vec::new(),
352+
};
353+
let target_module = match crate::parser::parse(&target_source) {
354+
Ok(m) => m,
355+
Err(_) => return Vec::new(),
356+
};
357+
target_module.doc_comments.iter().filter_map(|c| {
358+
if let cst::Comment::Doc(text) = c { Some(text.clone()) } else { None }
359+
}).collect()
360+
}
361+
303362
async fn get_local_kind(&self, module: &cst::Module, symbol: interner::Symbol) -> Option<String> {
304363
let registry = self.registry.read().await;
305364
let check_result = crate::typechecker::check_module_with_registry(module, &registry);

src/parser/grammar.lalrpop

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub Module: Module = {
2929
imports,
3030
decls,
3131
comments: Vec::new(),
32+
doc_comments: Vec::new(),
3233
}
3334
}
3435
};
@@ -152,6 +153,7 @@ ImportDecl: ImportDecl = {
152153
<qualified:("as" <ModuleName>)?> <end:@R> => {
153154
ImportDecl {
154155
span: Span::new(start, end),
156+
module_span: module.span,
155157
module: module.value,
156158
imports,
157159
qualified: qualified.map(|m| m.value),

src/parser/mod.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,14 @@ fn attach_comments(
147147
// Store all comments on the module
148148
module.comments = comment_pairs.clone();
149149

150+
// Attach doc-comments that appear before the `module` keyword to the module itself
151+
let module_start = module.span.start;
152+
module.doc_comments = comment_pairs
153+
.iter()
154+
.filter(|(c, span)| c.is_doc() && span.end <= module_start)
155+
.map(|(c, _)| c.clone())
156+
.collect();
157+
150158
if module.decls.is_empty() {
151159
return;
152160
}
@@ -278,6 +286,40 @@ mod tests {
278286
assert_eq!(module.decls[0].doc_comments().len(), 2);
279287
}
280288

289+
#[test]
290+
fn test_module_doc_comments() {
291+
let module = parse("-- | This module does things\nmodule Main where\nfoo = 1").unwrap();
292+
assert_eq!(module.doc_comments.len(), 1);
293+
assert!(module.doc_comments[0].is_doc());
294+
if let Comment::Doc(text) = &module.doc_comments[0] {
295+
assert_eq!(text.trim(), "This module does things");
296+
}
297+
}
298+
299+
#[test]
300+
fn test_module_multi_line_doc_comments() {
301+
let module = parse("-- | Line 1\n-- | Line 2\nmodule Main where\nfoo = 1").unwrap();
302+
assert_eq!(module.doc_comments.len(), 2);
303+
}
304+
305+
#[test]
306+
fn test_import_module_span() {
307+
let module = parse("module Main where\nimport Data.Maybe").unwrap();
308+
assert_eq!(module.imports.len(), 1);
309+
let imp = &module.imports[0];
310+
// "import Data.Maybe" — "Data.Maybe" starts at offset 25 (after "import ")
311+
let src = "module Main where\nimport Data.Maybe";
312+
assert_eq!(&src[imp.module_span.start..imp.module_span.end], "Data.Maybe");
313+
}
314+
315+
#[test]
316+
fn test_module_doc_not_confused_with_decl_doc() {
317+
// Doc comment after `where` should attach to the decl, not the module
318+
let module = parse("module Main where\n-- | Decl doc\nfoo = 1").unwrap();
319+
assert_eq!(module.doc_comments.len(), 0);
320+
assert_eq!(module.decls[0].doc_comments().len(), 1);
321+
}
322+
281323
// ===== Expression Tests: Literals =====
282324

283325
#[test]

src/typechecker/check.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8094,6 +8094,7 @@ fn check_module_impl(module: &Module, registry: &ModuleRegistry, collect_span_ty
80948094
.collect(),
80958095
class_superclasses: class_superclasses.clone(),
80968096
method_own_constraints: ctx.method_own_constraints.iter().map(|(k, v)| (qi(*k), v.clone())).collect(),
8097+
module_doc: Vec::new(), // filled in by the outer CST-level wrapper
80978098
};
80988099

80998100
// Ensure operator targets (e.g. Tuple for /\) are included in exported values and

src/typechecker/mod.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,18 @@ fn check_module_with_options(module: &crate::cst::Module, registry: &ModuleRegis
109109
span_types: HashMap::new(),
110110
};
111111
}
112-
if collect_span_types {
112+
let mut result = if collect_span_types {
113113
check::check_module_for_ide(&ast_module, registry)
114114
} else {
115115
check::check_module(&ast_module, registry)
116-
}
116+
};
117+
118+
// Propagate module-level doc-comments from CST to exports
119+
result.exports.module_doc = module.doc_comments.iter().filter_map(|c| {
120+
if let crate::cst::Comment::Doc(text) = c { Some(text.clone()) } else { None }
121+
}).collect();
122+
123+
result
117124
}
118125

119126
#[cfg(test)]

src/typechecker/registry.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ pub struct ModuleExports {
7676
/// Method-level constraint class names from class definitions.
7777
/// Maps method name → constraint class names. Used for current_given_expanded in instance methods.
7878
pub method_own_constraints: HashMap<QualifiedIdent, Vec<Symbol>>,
79+
/// Module-level doc-comments (appear before the `module` keyword)
80+
pub module_doc: Vec<String>,
7981
}
8082

8183
/// Registry of compiled modules, used to resolve imports.

tests/fixtures/lsp/hover/Simple.purs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ getName = myRecord.name
137137
-- 56:8 (r) => hover: Int
138138
--
139139
-- Line 2: import Simple.Lib (class Cl, member, ...)
140+
-- 2:7 (Simple.Lib) => hover: module Simple.Lib | doc: Utility functions and classes for Simple
140141
-- 2:29 (member) => hover: member
141142
-- 2:53 (Effect) => hover: Type -> Type | doc: Opaque effect type
142143
-- 2:45 (addOne) => hover: addOne | doc: Adds one to a number

tests/fixtures/lsp/hover/Simple/Lib.purs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
-- | Utility functions and classes for Simple
12
module Simple.Lib where
23

34
import Prelude

0 commit comments

Comments
 (0)