Skip to main content

nash_parse/
import.rs

1//! Import statement parsing for Nash.
2//!
3//! Ported from Elm's `Parse/Module.hs` (chompImport, chompAs, chompExposing).
4//!
5//! Parses import statements like:
6//! - `import List`
7//! - `import Json.Decode as Decode`
8//! - `import Html exposing (div, span)`
9//! - `import Platform.Cmd as Cmd exposing (Cmd)`
10
11use nash_region::Located;
12use nash_source::{Exposing, Import};
13
14use crate::Parser;
15use crate::error;
16
17impl<'a> Parser<'a> {
18    /// Parse an import statement.
19    ///
20    /// Mirrors Elm's `chompImport`:
21    /// ```text
22    /// import = 'import' module_name [ 'as' alias ] [ 'exposing' exposing_list ]
23    /// ```
24    pub fn import(&mut self) -> Result<&'a Import<'a>, error::Module<'a>> {
25        // Match 'import' keyword
26        self.keyword_import(error::Module::ImportStart)?;
27
28        self.chomp_and_check_indent(error::Module::Space, error::Module::ImportIndentName)?;
29
30        // Parse module name (e.g., "Json.Decode")
31        let start = self.get_position();
32        let name = self.module_name(error::Module::ImportName)?;
33        let module_name = self.add_end(start, name);
34
35        // Chomp whitespace (not indent-checked, could be end of line)
36        self.chomp(error::Module::Space)?;
37
38        // Check what comes next: fresh line, or continuation (as/exposing)
39        self.import_help(module_name)
40    }
41
42    /// Parse the continuation of an import after the module name.
43    ///
44    /// Handles: fresh line (done), `as Alias`, or `exposing (...)`.
45    fn import_help(
46        &mut self,
47        module_name: &'a Located<&'a str>,
48    ) -> Result<&'a Import<'a>, error::Module<'a>> {
49        // Try fresh line first (import done, no alias or exposing)
50        if self.col == 1 {
51            let default_exposing = self.alloc(Exposing::Explicit(&[]));
52            return Ok(self.alloc(Import {
53                import: module_name,
54                alias: None,
55                exposing: default_exposing,
56            }));
57        }
58
59        // Check that we're indented past the start
60        self.one_of(
61            error::Module::ImportAs,
62            vec![
63                // `as Alias [exposing (...)]`
64                Box::new(|p: &mut Parser<'a>| p.import_as(module_name)),
65                // `exposing (...)`
66                Box::new(|p: &mut Parser<'a>| p.import_exposing(module_name, None)),
67            ],
68        )
69    }
70
71    /// Parse `as Alias` part of an import.
72    ///
73    /// Mirrors Elm's `chompAs`.
74    fn import_as(
75        &mut self,
76        module_name: &'a Located<&'a str>,
77    ) -> Result<&'a Import<'a>, error::Module<'a>> {
78        self.keyword_as(error::Module::ImportAs)?;
79
80        self.chomp_and_check_indent(error::Module::Space, error::Module::ImportIndentAlias)?;
81
82        let alias = self.upper_name(error::Module::ImportAlias)?;
83
84        // Chomp whitespace
85        self.chomp(error::Module::Space)?;
86
87        // Check for exposing or end
88        if self.col == 1 {
89            // Fresh line - done
90            let default_exposing = self.alloc(Exposing::Explicit(&[]));
91            Ok(self.alloc(Import {
92                import: module_name,
93                alias: Some(alias),
94                exposing: default_exposing,
95            }))
96        } else {
97            // Must be exposing
98            self.import_exposing(module_name, Some(alias))
99        }
100    }
101
102    /// Parse `exposing (...)` part of an import.
103    ///
104    /// Mirrors Elm's `chompExposing`.
105    fn import_exposing(
106        &mut self,
107        module_name: &'a Located<&'a str>,
108        alias: Option<&'a str>,
109    ) -> Result<&'a Import<'a>, error::Module<'a>> {
110        self.keyword_exposing(error::Module::ImportExposing)?;
111
112        self.chomp_and_check_indent(
113            error::Module::Space,
114            error::Module::ImportIndentExposingList,
115        )?;
116
117        // Parse the exposing list, wrapping errors
118        let exposing = self.specialize(
119            |bump, err, row, col| error::Module::ImportExposingList(bump.alloc(err), row, col),
120            |p| p.exposing(),
121        )?;
122
123        // Check for fresh line (end of import)
124        self.chomp(error::Module::Space)?;
125        self.check_fresh_line(error::Module::ImportEnd)?;
126
127        Ok(self.alloc(Import {
128            import: module_name,
129            alias,
130            exposing: self.alloc(exposing),
131        }))
132    }
133
134    /// Parse a module name like "Json.Decode".
135    ///
136    /// Mirrors Elm's `Var.moduleName`:
137    /// ```text
138    /// module_name = upper_var { '.' upper_var }
139    /// ```
140    pub(crate) fn module_name<E>(
141        &mut self,
142        to_error: impl FnOnce(u16, u16) -> E,
143    ) -> Result<&'a str, E> {
144        let (row, col) = self.position();
145        let start_pos = self.pos;
146
147        // Must start with uppercase
148        match self.peek() {
149            Some(b) if b.is_ascii_uppercase() => {
150                self.advance();
151                self.chomp_inner_chars();
152            }
153            _ => return Err(to_error(row, col)),
154        }
155
156        // Continue with .Upper segments
157        loop {
158            if self.peek() == Some(b'.') {
159                // Check if followed by uppercase
160                if let Some(next) = self.peek_at(1)
161                    && next.is_ascii_uppercase()
162                {
163                    self.advance(); // consume '.'
164                    self.advance(); // consume first upper char
165                    self.chomp_inner_chars();
166                    continue;
167                }
168            }
169            // Not a dot or not followed by uppercase - done
170            break;
171        }
172
173        Ok(self.slice_from(start_pos))
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use bumpalo::Bump;
181    use indoc::indoc;
182
183    macro_rules! assert_import_snapshot {
184        ($input:expr) => {{
185            let input = indoc!($input);
186            let bump = Bump::new();
187            let src = bump.alloc_str(input);
188            let mut parser = Parser::new(&bump, src.as_bytes());
189            let result = parser.import();
190            match result {
191                Ok(ref import) => {
192                    insta::with_settings!({
193                        description => format!("Code:\n\n{}", input),
194                        omit_expression => true,
195                    }, {
196                        insta::assert_debug_snapshot!(import);
197                    });
198                }
199                Err(e) => {
200                    panic!("Expected successful parse, got error: {:?}", e);
201                }
202            }
203        }};
204    }
205
206    #[test]
207    fn import_simple() {
208        assert_import_snapshot!("import Foo\n");
209    }
210
211    #[test]
212    fn import_dotted() {
213        assert_import_snapshot!("import Json.Decode\n");
214    }
215
216    #[test]
217    fn import_deeply_nested() {
218        assert_import_snapshot!("import Platform.Cmd.Extra\n");
219    }
220
221    #[test]
222    fn import_with_alias() {
223        assert_import_snapshot!("import Json.Decode as Decode\n");
224    }
225
226    #[test]
227    fn import_exposing_open() {
228        assert_import_snapshot!("import List exposing (..)\n");
229    }
230
231    #[test]
232    fn import_exposing_explicit() {
233        assert_import_snapshot!("import Html exposing (div, span)\n");
234    }
235
236    #[test]
237    fn import_full() {
238        assert_import_snapshot!("import Platform.Cmd as Cmd exposing (Cmd)\n");
239    }
240
241    #[test]
242    fn import_exposing_types() {
243        assert_import_snapshot!("import Maybe exposing (Maybe(..))\n");
244    }
245}