Skip to main content

perl_module/import/
mod.rs

1//! Single-line Perl import head parsing.
2//!
3//! Parse a single source line that starts with `use` or `require` and return
4//! the first import token with stable byte offsets.
5
6/// When a module is loaded relative to program execution.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum LoadTiming {
9    /// Module is loaded at compile time (e.g. `use`).
10    CompileTime,
11    /// Module is loaded at runtime (e.g. `require`).
12    Runtime,
13}
14
15/// Whether the module's `import` method is called after loading.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ImportBehavior {
18    /// The module's `import` method is called (as with `use`).
19    CallsImport,
20    /// No `import` call is made (as with `require`).
21    NoImport,
22}
23
24/// Semantic description of a `use`/`require` dispatch form.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct DispatchSemantics {
27    /// When the module load happens.
28    pub load_timing: LoadTiming,
29    /// Whether `import` is called on the loaded module.
30    pub import_behavior: ImportBehavior,
31}
32
33impl DispatchSemantics {
34    /// A short human-readable description suitable for hover text.
35    #[must_use]
36    pub fn hover_description(&self) -> &'static str {
37        match (self.load_timing, self.import_behavior) {
38            (LoadTiming::CompileTime, ImportBehavior::CallsImport) => {
39                "compile-time load; calls import()"
40            }
41            (LoadTiming::Runtime, ImportBehavior::NoImport) => "runtime load; no import() call",
42            (LoadTiming::CompileTime, ImportBehavior::NoImport) => {
43                "compile-time load; no import() call"
44            }
45            (LoadTiming::Runtime, ImportBehavior::CallsImport) => "runtime load; calls import()",
46        }
47    }
48}
49
50/// How a `use` statement spells its import list.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ImportListForm {
53    /// `use Module;`
54    Default,
55    /// `use Module ();`
56    Empty,
57    /// `use Module (...)`
58    Explicit,
59}
60
61/// Distinguishes the two syntactic forms of `require`.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum RequireForm {
64    /// `require Module::Name` — bare module name.
65    ModuleName,
66    /// `require "path/to/file.pm"` or `require 'path/to/file.pm'` — quoted file path.
67    FilePath,
68}
69
70/// Classifies the import statement form for a parsed line.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum ModuleImportKind {
73    /// `use Module::Name;`
74    Use,
75    /// `require Module::Name;` or `require "file.pm";`
76    Require,
77    /// `use parent ...`
78    UseParent,
79    /// `use base ...`
80    UseBase,
81}
82
83impl ModuleImportKind {
84    /// Returns the dispatch semantics for this import kind.
85    #[must_use]
86    pub fn dispatch_semantics(self) -> DispatchSemantics {
87        match self {
88            ModuleImportKind::Use | ModuleImportKind::UseParent | ModuleImportKind::UseBase => {
89                DispatchSemantics {
90                    load_timing: LoadTiming::CompileTime,
91                    import_behavior: ImportBehavior::CallsImport,
92                }
93            }
94            ModuleImportKind::Require => DispatchSemantics {
95                load_timing: LoadTiming::Runtime,
96                import_behavior: ImportBehavior::NoImport,
97            },
98        }
99    }
100}
101
102/// Parsed leading import token from a `use`/`require` line.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct ModuleImportHead<'a> {
105    /// Parsed statement kind.
106    pub kind: ModuleImportKind,
107    /// First token after `use` or `require` (quotes stripped for file-path forms).
108    pub token: &'a str,
109    /// Inclusive byte start offset of `token` in the full line.
110    pub token_start: usize,
111    /// Exclusive byte end offset of `token` in the full line.
112    pub token_end: usize,
113    /// For `require`, whether the argument was a quoted file path or a bare module name.
114    /// Always `None` for `use` forms.
115    require_form: Option<RequireForm>,
116    /// For `use` statements, how the import list is spelled.
117    pub import_list: Option<ImportListForm>,
118}
119
120/// Resolve a known export tag to its symbol list for a specific module.
121///
122/// The `tag` argument can be passed with or without a leading `:`.
123/// Returns `None` when the module/tag pair is not in the built-in catalog.
124#[must_use]
125pub fn resolve_known_export_tag(module: &str, tag: &str) -> Option<&'static [&'static str]> {
126    let normalized_tag = tag.strip_prefix(':').unwrap_or(tag);
127    match (module, normalized_tag) {
128        ("POSIX", "sys_wait_h") => Some(&["WIFEXITED", "WEXITSTATUS", "WIFSIGNALED", "WTERMSIG"]),
129        ("POSIX", "fcntl_h") => Some(&["F_GETFL", "F_SETFL", "F_SETFD", "F_GETFD"]),
130        ("POSIX", "termios_h") => Some(&["TCSANOW", "TCSADRAIN", "TCSAFLUSH", "B9600"]),
131        ("File::Find", "find") => Some(&["find", "finddepth"]),
132        ("Fcntl", "seek") => Some(&["SEEK_SET", "SEEK_CUR", "SEEK_END"]),
133        ("Fcntl", "lock") => Some(&["LOCK_SH", "LOCK_EX", "LOCK_NB", "LOCK_UN"]),
134        ("Encode", "fallback") => Some(&["FB_DEFAULT", "FB_CROAK", "FB_QUIET", "FB_WARN"]),
135        _ => None,
136    }
137}
138
139impl<'a> ModuleImportHead<'a> {
140    /// Returns the [`RequireForm`] for `require` statements, or `None` for `use` forms.
141    #[must_use]
142    pub fn require_form(&self) -> Option<RequireForm> {
143        self.require_form
144    }
145}
146
147/// Parse the leading import token of a single Perl source line.
148///
149/// Returns [`None`] when the line does not start with `use` or `require`
150/// (after leading whitespace) or when no token is present after the keyword.
151#[must_use]
152pub fn parse_module_import_head(line: &str) -> Option<ModuleImportHead<'_>> {
153    if let Some((token, token_start, token_end)) = parse_statement_head(line, "use") {
154        let kind = match token {
155            "parent" => ModuleImportKind::UseParent,
156            "base" => ModuleImportKind::UseBase,
157            _ => ModuleImportKind::Use,
158        };
159
160        let import_list = match kind {
161            ModuleImportKind::Use => Some(classify_use_import_list(&line[token_end..])),
162            ModuleImportKind::UseParent | ModuleImportKind::UseBase => None,
163            ModuleImportKind::Require => None,
164        };
165
166        return Some(ModuleImportHead {
167            kind,
168            token,
169            token_start,
170            token_end,
171            require_form: None,
172            import_list,
173        });
174    }
175
176    if let Some(result) = parse_require_head(line) {
177        return Some(result);
178    }
179
180    None
181}
182
183/// Parse a `require` statement, handling both bare module names and quoted file paths.
184fn parse_require_head(line: &str) -> Option<ModuleImportHead<'_>> {
185    let trimmed = line.trim_start();
186    let leading = line.len().saturating_sub(trimmed.len());
187
188    let rest = trimmed.strip_prefix("require")?;
189    if !rest.chars().next().is_some_and(char::is_whitespace) {
190        return None;
191    }
192
193    let after_keyword = leading + "require".len();
194
195    let rest_trimmed = rest.trim_start();
196    let quote_offset = rest.len() - rest_trimmed.len();
197
198    if let Some(quote_char) = rest_trimmed.chars().next().filter(|ch| *ch == '"' || *ch == '\'') {
199        let quoted = &rest_trimmed[quote_char.len_utf8()..];
200        let close_idx = quoted.find(quote_char)?;
201        let inner = &quoted[..close_idx];
202
203        let token_start = after_keyword + quote_offset + quote_char.len_utf8();
204        let token_end = token_start + inner.len();
205        return Some(ModuleImportHead {
206            kind: ModuleImportKind::Require,
207            token: inner,
208            token_start,
209            token_end,
210            require_form: Some(RequireForm::FilePath),
211            import_list: None,
212        });
213    }
214
215    let (token, token_rel_start, token_rel_end) = first_token_with_range(rest)?;
216    let token_start = after_keyword + token_rel_start;
217    let token_end = after_keyword + token_rel_end;
218
219    Some(ModuleImportHead {
220        kind: ModuleImportKind::Require,
221        token,
222        token_start,
223        token_end,
224        require_form: Some(RequireForm::ModuleName),
225        import_list: None,
226    })
227}
228
229fn classify_use_import_list(rest: &str) -> ImportListForm {
230    let trimmed = rest.trim_start();
231
232    if trimmed.is_empty() || trimmed.starts_with(';') {
233        return ImportListForm::Default;
234    }
235
236    if let Some(after_open) = trimmed.strip_prefix('(')
237        && let Some(close_idx) = after_open.find(')')
238        && after_open[..close_idx].trim().is_empty()
239    {
240        let after_close = after_open[close_idx + 1..].trim_start();
241        if after_close.is_empty() || after_close.starts_with(';') || after_close.starts_with('#') {
242            return ImportListForm::Empty;
243        }
244    }
245
246    ImportListForm::Explicit
247}
248
249fn parse_statement_head<'a>(line: &'a str, keyword: &str) -> Option<(&'a str, usize, usize)> {
250    let trimmed = line.trim_start();
251    let leading = line.len().saturating_sub(trimmed.len());
252
253    let rest = trimmed.strip_prefix(keyword)?;
254    if !rest.chars().next().is_some_and(char::is_whitespace) {
255        return None;
256    }
257
258    let (token, token_rel_start, token_rel_end) = first_token_with_range(rest)?;
259    let token_start = leading + keyword.len() + token_rel_start;
260    let token_end = leading + keyword.len() + token_rel_end;
261
262    Some((token, token_start, token_end))
263}
264
265fn first_token_with_range(input: &str) -> Option<(&str, usize, usize)> {
266    let mut token_start = None;
267
268    for (idx, ch) in input.char_indices() {
269        match token_start {
270            None => {
271                if is_token_delimiter(ch) {
272                    continue;
273                }
274                token_start = Some(idx);
275            }
276            Some(start) => {
277                if is_token_delimiter(ch) {
278                    if start == idx {
279                        return None;
280                    }
281                    return Some((&input[start..idx], start, idx));
282                }
283            }
284        }
285    }
286
287    if let Some(start) = token_start {
288        if start < input.len() { Some((&input[start..], start, input.len())) } else { None }
289    } else {
290        None
291    }
292}
293
294fn is_token_delimiter(ch: char) -> bool {
295    ch.is_whitespace() || matches!(ch, ';' | '(' | ')')
296}