Skip to main content

perl_module_import/
lib.rs

1//! Single-line Perl import head parsing.
2//!
3//! This crate provides one narrow responsibility: parse a single source line
4//! that starts with `use` or `require` and return the first import token with
5//! stable byte offsets.
6
7#![deny(unsafe_code)]
8#![warn(rust_2018_idioms)]
9#![warn(missing_docs)]
10#![warn(clippy::all)]
11
12/// When a module is loaded relative to program execution.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum LoadTiming {
15    /// Module is loaded at compile time (e.g. `use`).
16    CompileTime,
17    /// Module is loaded at runtime (e.g. `require`).
18    Runtime,
19}
20
21/// Whether the module's `import` method is called after loading.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ImportBehavior {
24    /// The module's `import` method is called (as with `use`).
25    CallsImport,
26    /// No `import` call is made (as with `require`).
27    NoImport,
28}
29
30/// Semantic description of a `use`/`require` dispatch form.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct DispatchSemantics {
33    /// When the module load happens.
34    pub load_timing: LoadTiming,
35    /// Whether `import` is called on the loaded module.
36    pub import_behavior: ImportBehavior,
37}
38
39impl DispatchSemantics {
40    /// A short human-readable description suitable for hover text.
41    #[must_use]
42    pub fn hover_description(&self) -> &'static str {
43        match (self.load_timing, self.import_behavior) {
44            (LoadTiming::CompileTime, ImportBehavior::CallsImport) => {
45                "compile-time load; calls import()"
46            }
47            (LoadTiming::Runtime, ImportBehavior::NoImport) => "runtime load; no import() call",
48            (LoadTiming::CompileTime, ImportBehavior::NoImport) => {
49                "compile-time load; no import() call"
50            }
51            (LoadTiming::Runtime, ImportBehavior::CallsImport) => "runtime load; calls import()",
52        }
53    }
54}
55
56/// Distinguishes the two syntactic forms of `require`.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum RequireForm {
59    /// `require Module::Name` — bare module name.
60    ModuleName,
61    /// `require "path/to/file.pm"` or `require 'path/to/file.pm'` — quoted file path.
62    FilePath,
63}
64
65/// Classifies the import statement form for a parsed line.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ModuleImportKind {
68    /// `use Module::Name;`
69    Use,
70    /// `require Module::Name;` or `require "file.pm";`
71    Require,
72    /// `use parent ...`
73    UseParent,
74    /// `use base ...`
75    UseBase,
76}
77
78impl ModuleImportKind {
79    /// Returns the dispatch semantics for this import kind.
80    #[must_use]
81    pub fn dispatch_semantics(self) -> DispatchSemantics {
82        match self {
83            ModuleImportKind::Use | ModuleImportKind::UseParent | ModuleImportKind::UseBase => {
84                DispatchSemantics {
85                    load_timing: LoadTiming::CompileTime,
86                    import_behavior: ImportBehavior::CallsImport,
87                }
88            }
89            ModuleImportKind::Require => DispatchSemantics {
90                load_timing: LoadTiming::Runtime,
91                import_behavior: ImportBehavior::NoImport,
92            },
93        }
94    }
95}
96
97/// Parsed leading import token from a `use`/`require` line.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub struct ModuleImportHead<'a> {
100    /// Parsed statement kind.
101    pub kind: ModuleImportKind,
102    /// First token after `use` or `require` (quotes stripped for file-path forms).
103    pub token: &'a str,
104    /// Inclusive byte start offset of `token` in the full line.
105    pub token_start: usize,
106    /// Exclusive byte end offset of `token` in the full line.
107    pub token_end: usize,
108    /// For `require`, whether the argument was a quoted file path or a bare module name.
109    /// Always `None` for `use` forms.
110    require_form: Option<RequireForm>,
111}
112
113impl<'a> ModuleImportHead<'a> {
114    /// Returns the [`RequireForm`] for `require` statements, or `None` for `use` forms.
115    #[must_use]
116    pub fn require_form(&self) -> Option<RequireForm> {
117        self.require_form
118    }
119}
120
121/// Parse the leading import token of a single Perl source line.
122///
123/// Returns [`None`] when the line does not start with `use` or `require`
124/// (after leading whitespace) or when no token is present after the keyword.
125///
126/// For `require "file.pm"` and `require 'file.pm'` forms, the surrounding
127/// quotes are stripped and the inner path is returned as `token`.
128///
129/// # Examples
130///
131/// ```
132/// use perl_module_import::{ModuleImportKind, parse_module_import_head};
133///
134/// let parsed = parse_module_import_head("use Foo::Bar;");
135/// assert_eq!(parsed.map(|head| head.kind), Some(ModuleImportKind::Use));
136/// assert_eq!(parsed.map(|head| head.token), Some("Foo::Bar"));
137///
138/// let parsed = parse_module_import_head("use parent 'Foo::Bar';");
139/// assert_eq!(parsed.map(|head| head.kind), Some(ModuleImportKind::UseParent));
140/// assert_eq!(parsed.map(|head| head.token), Some("parent"));
141/// ```
142#[must_use]
143pub fn parse_module_import_head(line: &str) -> Option<ModuleImportHead<'_>> {
144    if let Some((token, token_start, token_end)) = parse_statement_head(line, "use") {
145        let kind = match token {
146            "parent" => ModuleImportKind::UseParent,
147            "base" => ModuleImportKind::UseBase,
148            _ => ModuleImportKind::Use,
149        };
150        return Some(ModuleImportHead { kind, token, token_start, token_end, require_form: None });
151    }
152
153    if let Some(result) = parse_require_head(line) {
154        return Some(result);
155    }
156
157    None
158}
159
160/// Parse a `require` statement, handling both bare module names and quoted file paths.
161fn parse_require_head(line: &str) -> Option<ModuleImportHead<'_>> {
162    let trimmed = line.trim_start();
163    let leading = line.len().saturating_sub(trimmed.len());
164
165    let rest = trimmed.strip_prefix("require")?;
166    if !rest.chars().next().is_some_and(char::is_whitespace) {
167        return None;
168    }
169
170    let after_keyword = leading + "require".len();
171
172    // Check for quoted file-path form: require "..." or require '...'
173    let rest_trimmed = rest.trim_start();
174    let quote_offset = rest.len() - rest_trimmed.len();
175
176    if let Some(inner) = rest_trimmed
177        .strip_prefix('"')
178        .and_then(|s| s.strip_suffix('"').or_else(|| s.split('"').next()))
179        .or_else(|| {
180            rest_trimmed
181                .strip_prefix('\'')
182                .and_then(|s| s.strip_suffix('\'').or_else(|| s.split('\'').next()))
183        })
184    {
185        // Quoted form: token is the content inside the quotes, offsets point inside them
186        let quote_char_len = 1usize; // single byte for ' or "
187        let token_start = after_keyword + quote_offset + quote_char_len;
188        let token_end = token_start + inner.len();
189        return Some(ModuleImportHead {
190            kind: ModuleImportKind::Require,
191            token: inner,
192            token_start,
193            token_end,
194            require_form: Some(RequireForm::FilePath),
195        });
196    }
197
198    // Bare module name form
199    let (token, token_rel_start, token_rel_end) = first_token_with_range(rest)?;
200    let token_start = after_keyword + token_rel_start;
201    let token_end = after_keyword + token_rel_end;
202
203    Some(ModuleImportHead {
204        kind: ModuleImportKind::Require,
205        token,
206        token_start,
207        token_end,
208        require_form: Some(RequireForm::ModuleName),
209    })
210}
211
212fn parse_statement_head<'a>(line: &'a str, keyword: &str) -> Option<(&'a str, usize, usize)> {
213    let trimmed = line.trim_start();
214    let leading = line.len().saturating_sub(trimmed.len());
215
216    let rest = trimmed.strip_prefix(keyword)?;
217    if !rest.chars().next().is_some_and(char::is_whitespace) {
218        return None;
219    }
220
221    let (token, token_rel_start, token_rel_end) = first_token_with_range(rest)?;
222    let token_start = leading + keyword.len() + token_rel_start;
223    let token_end = leading + keyword.len() + token_rel_end;
224
225    Some((token, token_start, token_end))
226}
227
228fn first_token_with_range(input: &str) -> Option<(&str, usize, usize)> {
229    let mut token_start = None;
230
231    for (idx, ch) in input.char_indices() {
232        match token_start {
233            None => {
234                if is_token_delimiter(ch) {
235                    continue;
236                }
237                token_start = Some(idx);
238            }
239            Some(start) => {
240                if is_token_delimiter(ch) {
241                    if start == idx {
242                        return None;
243                    }
244                    return Some((&input[start..idx], start, idx));
245                }
246            }
247        }
248    }
249
250    if let Some(start) = token_start {
251        if start < input.len() { Some((&input[start..], start, input.len())) } else { None }
252    } else {
253        None
254    }
255}
256
257fn is_token_delimiter(ch: char) -> bool {
258    ch.is_whitespace() || matches!(ch, ';' | '(' | ')')
259}
260
261#[cfg(test)]
262mod tests {
263    use super::{ModuleImportKind, parse_module_import_head};
264
265    #[test]
266    fn parses_use_statement_head() {
267        let parsed = parse_module_import_head("use Foo::Bar;");
268
269        assert!(parsed.is_some());
270        if let Some(head) = parsed {
271            assert_eq!(head.kind, ModuleImportKind::Use);
272            assert_eq!(head.token, "Foo::Bar");
273            assert_eq!(head.token_start, 4);
274            assert_eq!(head.token_end, 12);
275        }
276    }
277
278    #[test]
279    fn parses_require_statement_head() {
280        let parsed = parse_module_import_head("  require Foo::Bar;");
281
282        assert!(parsed.is_some());
283        if let Some(head) = parsed {
284            assert_eq!(head.kind, ModuleImportKind::Require);
285            assert_eq!(head.token, "Foo::Bar");
286            assert_eq!(head.token_start, 10);
287            assert_eq!(head.token_end, 18);
288        }
289    }
290
291    #[test]
292    fn classifies_parent_and_base_specializations() {
293        let parent = parse_module_import_head("use parent qw(Foo::Bar);");
294        let base = parse_module_import_head("use base 'Foo::Bar';");
295
296        assert!(parent.is_some());
297        if let Some(head) = parent {
298            assert_eq!(head.kind, ModuleImportKind::UseParent);
299            assert_eq!(head.token, "parent");
300        }
301
302        assert!(base.is_some());
303        if let Some(head) = base {
304            assert_eq!(head.kind, ModuleImportKind::UseBase);
305            assert_eq!(head.token, "base");
306        }
307    }
308
309    #[test]
310    fn rejects_non_keyword_boundaries() {
311        assert!(parse_module_import_head("user Foo::Bar;").is_none());
312        assert!(parse_module_import_head("required Foo::Bar;").is_none());
313    }
314
315    #[test]
316    fn rejects_missing_tokens() {
317        assert!(parse_module_import_head("use ;").is_none());
318        assert!(parse_module_import_head("require").is_none());
319    }
320}