Skip to main content

elm_ast/
file.rs

1use crate::comment::Comment;
2use crate::declaration::Declaration;
3use crate::import::Import;
4use crate::module_header::ModuleHeader;
5use crate::node::Spanned;
6use crate::token::Token;
7
8/// The root AST node representing a complete Elm source file.
9///
10/// Corresponds to:
11/// - `Module` in `AST/Source.hs`
12/// - `File` in `Elm.Syntax.File`
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct ElmModule {
16    /// The module header declaration.
17    pub header: Spanned<ModuleHeader>,
18
19    /// Module-level documentation comment (appears after the header, before imports).
20    ///
21    /// In Elm, a `{-| ... -}` comment immediately after the `module ... exposing (...)`
22    /// header is the module's documentation. It is distinct from comments attached
23    /// to individual declarations.
24    #[cfg_attr(
25        feature = "serde",
26        serde(default, skip_serializing_if = "Option::is_none")
27    )]
28    pub module_documentation: Option<Spanned<String>>,
29
30    /// Import declarations, in source order.
31    pub imports: Vec<Spanned<Import>>,
32
33    /// Top-level declarations, in source order.
34    pub declarations: Vec<Spanned<Declaration>>,
35
36    /// Comments that are not attached to any declaration.
37    ///
38    /// These are collected separately to allow round-trip fidelity
39    /// when printing the AST back to source.
40    pub comments: Vec<Spanned<Comment>>,
41}
42
43impl ElmModule {
44    /// Get comments that appear immediately before a declaration.
45    ///
46    /// A comment is considered "leading" if it appears between the end of
47    /// the previous declaration (or the last import, or the module header)
48    /// and the start of this declaration.
49    ///
50    /// Note: only comments that were captured during parsing are available.
51    /// For complete comment extraction, use [`extract_comments`] on the
52    /// original token stream.
53    pub fn leading_comments(&self, decl_index: usize) -> Vec<&Spanned<Comment>> {
54        if decl_index >= self.declarations.len() {
55            return Vec::new();
56        }
57
58        let decl_start = self.declarations[decl_index].span.start.offset;
59
60        // Find the end of the previous item.
61        let prev_end = if decl_index > 0 {
62            self.declarations[decl_index - 1].span.end.offset
63        } else if let Some(last_import) = self.imports.last() {
64            last_import.span.end.offset
65        } else {
66            self.header.span.end.offset
67        };
68
69        self.comments
70            .iter()
71            .filter(|c| c.span.start.offset > prev_end && c.span.end.offset <= decl_start)
72            .collect()
73    }
74
75    /// Get comments that appear on the same line as the end of a declaration
76    /// (trailing line comments).
77    pub fn trailing_comment(&self, decl_index: usize) -> Option<&Spanned<Comment>> {
78        if decl_index >= self.declarations.len() {
79            return None;
80        }
81
82        let decl_end_line = self.declarations[decl_index].span.end.line;
83
84        // Find the next item's start to bound the search.
85        let next_start = if decl_index + 1 < self.declarations.len() {
86            self.declarations[decl_index + 1].span.start.offset
87        } else {
88            usize::MAX
89        };
90
91        self.comments.iter().find(|c| {
92            c.span.start.line == decl_end_line
93                && c.span.start.offset < next_start
94                && matches!(c.value, Comment::Line(_))
95        })
96    }
97
98    /// Get all comments that appear before any declarations (module-level
99    /// header comments, after imports).
100    pub fn module_comments(&self) -> Vec<&Spanned<Comment>> {
101        let first_decl_start = self
102            .declarations
103            .first()
104            .map(|d| d.span.start.offset)
105            .unwrap_or(usize::MAX);
106
107        self.comments
108            .iter()
109            .filter(|c| c.span.end.offset <= first_decl_start)
110            .collect()
111    }
112}
113
114/// Extract all comments from a token stream.
115///
116/// This provides complete comment coverage — unlike `ElmModule.comments`,
117/// which may miss comments consumed by `skip_whitespace` during parsing.
118/// Use this when you need every comment in the source.
119pub fn extract_comments(tokens: &[Spanned<Token>]) -> Vec<Spanned<Comment>> {
120    tokens
121        .iter()
122        .filter_map(|tok| match &tok.value {
123            Token::LineComment(text) => Some(Spanned::new(tok.span, Comment::Line(text.clone()))),
124            Token::BlockComment(text) => Some(Spanned::new(tok.span, Comment::Block(text.clone()))),
125            Token::DocComment(text) => Some(Spanned::new(tok.span, Comment::Doc(text.clone()))),
126            _ => None,
127        })
128        .collect()
129}
130
131/// Associate comments with declarations by source position.
132///
133/// Returns a vec parallel to `module.declarations` where each entry
134/// is the list of comments that appear between the previous declaration
135/// (or imports/header) and this declaration.
136/// Associate comments with declarations by source line position.
137///
138/// Returns a vec parallel to `module.declarations` where each entry
139/// is the list of comments that appear between the previous declaration's
140/// start line and this declaration's start line.
141pub fn associate_comments(
142    module: &ElmModule,
143    all_comments: &[Spanned<Comment>],
144) -> Vec<Vec<Spanned<Comment>>> {
145    let mut result = Vec::with_capacity(module.declarations.len());
146
147    for (i, decl) in module.declarations.iter().enumerate() {
148        let decl_start_line = decl.span.start.line;
149
150        // Previous declaration's start line (or import/header end line).
151        let prev_start_line = if i > 0 {
152            module.declarations[i - 1].span.start.line
153        } else if let Some(last_import) = module.imports.last() {
154            last_import.span.end.line
155        } else {
156            module.header.span.end.line
157        };
158
159        let leading: Vec<Spanned<Comment>> = all_comments
160            .iter()
161            .filter(|c| c.span.start.line > prev_start_line && c.span.start.line < decl_start_line)
162            .cloned()
163            .collect();
164
165        result.push(leading);
166    }
167
168    result
169}