Skip to main content

miniextendr_lint/
crate_root.rs

1//! Where the module walk starts: the library target's root file.
2//!
3//! Cargo takes it from `[lib] path` in `Cargo.toml`. Without that key the lint
4//! keeps its two guesses: `src/lib.rs` when the crate has a `src/` directory,
5//! else `lib.rs` next to `Cargo.toml` (every scaffolded R package sets
6//! `[lib] path = "lib.rs"`, so the guess only matters for manifests without it).
7//!
8//! The key is read with a small line-based scanner, so the lint gains no TOML
9//! dependency. Mirror of the reader in `miniextendr-macros/src/crate_config.rs`
10//! (`normalize_key`, `parse_string_value`): the current table header is tracked
11//! and every `key = value` line resolves to a dotted path, so `[lib]` followed by
12//! `path = "..."` and a top-level `lib.path = "..."` are the same key, and a
13//! `path` under any other table (`[[bin]]`, `[package]`, `[dependencies.lib]`) is
14//! a different one.
15
16use std::fmt;
17use std::fs;
18use std::path::{Path, PathBuf};
19
20/// Dotted path of the key that names the library target's root file.
21const LIB_PATH_KEY: &str = "lib.path";
22
23/// Where [`CrateRoot::file`] came from.
24#[derive(#[automatically_derived]
#[doc(hidden)]
unsafe impl ::core::clone::TrivialClone for RootOrigin { }
#[automatically_derived]
impl ::core::clone::Clone for RootOrigin {
    #[inline]
    fn clone(&self) -> Self { *self }
}Clone, #[automatically_derived]
impl ::core::marker::Copy for RootOrigin { }Copy, #[automatically_derived]
impl ::core::fmt::Debug for RootOrigin {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::write_str(f,
            match self {
                RootOrigin::Manifest => "Manifest",
                RootOrigin::Default => "Default",
            })
    }
}Debug, #[automatically_derived]
impl ::core::marker::StructuralPartialEq for RootOrigin { }
#[automatically_derived]
impl ::core::cmp::PartialEq for RootOrigin {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        ::core::intrinsics::discriminant_value(self) ==
            ::core::intrinsics::discriminant_value(other)
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for RootOrigin { }Eq)]
25pub enum RootOrigin {
26    /// `[lib] path` in `Cargo.toml`.
27    Manifest,
28    /// No `[lib] path`: `src/lib.rs` when `src/` exists, else `lib.rs`.
29    Default,
30}
31
32/// The library target's root file, resolved for a manifest directory.
33#[derive(#[automatically_derived]
impl ::core::clone::Clone for CrateRoot {
    #[inline]
    fn clone(&self) -> Self {
        CrateRoot {
            file: ::core::clone::Clone::clone(&self.file),
            origin: ::core::clone::Clone::clone(&self.origin),
        }
    }
}Clone, #[automatically_derived]
impl ::core::fmt::Debug for CrateRoot {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field2_finish(f, "CrateRoot",
            "file", &self.file, "origin", &&self.origin)
    }
}Debug, #[automatically_derived]
impl ::core::marker::StructuralPartialEq for CrateRoot { }
#[automatically_derived]
impl ::core::cmp::PartialEq for CrateRoot {
    #[inline]
    fn eq(&self, other: &Self) -> bool {
        self.file == other.file && self.origin == other.origin
    }
}PartialEq, #[automatically_derived]
impl ::core::cmp::Eq for CrateRoot {
    #[inline]
    #[doc(hidden)]
    #[coverage(off)]
    fn assert_fields_are_eq(&self) {
        let _: ::core::cmp::AssertParamIsEq<PathBuf>;
        let _: ::core::cmp::AssertParamIsEq<RootOrigin>;
    }
}Eq)]
34pub struct CrateRoot {
35    /// The root file. It may not exist; [`crate::CrateIndex::build`] reports that.
36    pub file: PathBuf,
37    /// Whether the path came from `[lib] path` or from the default guess.
38    pub origin: RootOrigin,
39}
40
41impl CrateRoot {
42    /// Resolve the root file of the crate whose `Cargo.toml` sits in `manifest_dir`.
43    ///
44    /// A missing or unreadable `Cargo.toml` resolves to the default guess. Only a
45    /// `[lib] path` the scanner cannot read is an error, so the lint never walks
46    /// a guessed tree while the manifest names another one.
47    pub fn resolve(manifest_dir: &Path) -> Result<Self, String> {
48        let manifest = manifest_dir.join("Cargo.toml");
49        let lib_path = match fs::read_to_string(&manifest) {
50            Ok(text) => lib_path_from_manifest(&text).map_err(|message| {
51                ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("miniextendr-lint: {0}: {1}",
                manifest.display(), message))
    })format!("miniextendr-lint: {}: {message}", manifest.display())
52            })?,
53            Err(_) => None,
54        };
55
56        Ok(match lib_path {
57            Some(path) => Self {
58                file: normalize(&manifest_dir.join(path)),
59                origin: RootOrigin::Manifest,
60            },
61            None => {
62                let src_dir = manifest_dir.join("src");
63                let dir = if src_dir.is_dir() {
64                    src_dir
65                } else {
66                    manifest_dir.to_path_buf()
67                };
68                Self {
69                    file: dir.join("lib.rs"),
70                    origin: RootOrigin::Default,
71                }
72            }
73        })
74    }
75}
76
77impl fmt::Display for RootOrigin {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        f.write_str(match self {
80            Self::Manifest => "[lib] path in Cargo.toml",
81            Self::Default => "no [lib] path in Cargo.toml",
82        })
83    }
84}
85
86/// Whether the crate sets `roxygen_prose_links = "keep"` under
87/// `[package.metadata.miniextendr]`: leading doc prose then keeps its links on
88/// the way to roxygen2. Same line scanner as [`lib_path_from_manifest`]; a
89/// missing, unreadable or malformed value reads as the default (`"strip"`).
90pub(crate) fn prose_links_keep(manifest_dir: &Path) -> bool {
91    const KEY: &str = "package.metadata.miniextendr.roxygen_prose_links";
92    let Ok(text) = fs::read_to_string(manifest_dir.join("Cargo.toml")) else {
93        return false;
94    };
95    let mut table = String::new();
96    for raw_line in text.lines() {
97        let line = raw_line.trim();
98        if line.is_empty() || line.starts_with('#') {
99            continue;
100        }
101        if let Some(header) = line.strip_prefix('[') {
102            let header = header.strip_prefix('[').unwrap_or(header);
103            if let Some(end) = header.find(']') {
104                table = normalize_key(&header[..end]);
105            }
106            continue;
107        }
108        let Some((key, value)) = line.split_once('=') else {
109            continue;
110        };
111        let key = normalize_key(key);
112        let full = if table.is_empty() {
113            key
114        } else {
115            ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}.{1}", table, key))
    })format!("{table}.{key}")
116        };
117        if full == KEY {
118            return parse_string_value(value.trim()).is_some_and(|v| v == "keep");
119        }
120    }
121    false
122}
123
124/// `manifest_dir.join(path)` without `.` components, so `path = "./lib.rs"`
125/// names the same file as `path = "lib.rs"`.
126fn normalize(path: &Path) -> PathBuf {
127    path.components().collect()
128}
129
130/// The `[lib] path` value of manifest text, `None` when the key is absent.
131///
132/// Errors when the key is present but its value is not a single-line string
133/// without escapes, or when the `[lib]` table is written inline with a `path`.
134fn lib_path_from_manifest(text: &str) -> Result<Option<String>, String> {
135    let mut table = String::new();
136
137    for raw_line in text.lines() {
138        let line = raw_line.trim();
139        if line.is_empty() || line.starts_with('#') {
140            continue;
141        }
142        if let Some(header) = line.strip_prefix('[') {
143            // `[[array.of.tables]]` and `[table]`: keys below belong to it. A
144            // line with no `]` (a nested array spread over lines) opens no
145            // table; the lint skips it where the macro crate's copy errors, so
146            // an unrelated value never costs the crate its lint.
147            let header = header.strip_prefix('[').unwrap_or(header);
148            if let Some(end) = header.find(']') {
149                table = normalize_key(&header[..end]);
150            }
151            continue;
152        }
153        let Some((key, value)) = line.split_once('=') else {
154            continue;
155        };
156        let key = normalize_key(key);
157        let full = if table.is_empty() {
158            key
159        } else {
160            ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("{0}.{1}", table, key))
    })format!("{table}.{key}")
161        };
162        let value = value.trim();
163        if full == "lib" && value.starts_with('{') && inline_table_has_path(value) {
164            return Err(
165                "an inline `lib = { path = ... }` table is not read; write a `[lib]` table with \
166                 `path = \"...\"` on its own line"
167                    .to_string(),
168            );
169        }
170        if full != LIB_PATH_KEY {
171            continue;
172        }
173        return parse_string_value(value).map(Some).ok_or_else(|| {
174            ::alloc::__export::must_use({
        ::alloc::fmt::format(format_args!("cannot read [lib] path `{0}`; write it as a one-line string without escapes",
                value))
    })format!(
175                "cannot read [lib] path `{value}`; write it as a one-line string without escapes"
176            )
177        });
178    }
179
180    Ok(None)
181}
182
183/// Whether a one-line inline table names a `path` key. Splitting on `,` can cut
184/// an array or a string apart, but every key still starts a piece of its own.
185fn inline_table_has_path(value: &str) -> bool {
186    value
187        .trim_start_matches('{')
188        .split(',')
189        .filter_map(|piece| piece.split_once('='))
190        .any(|(key, _)| normalize_key(key) == "path")
191}
192
193/// Collapse a dotted key or table header to `a.b.c`: trims each segment and
194/// strips one layer of quotes so `[ "lib" ]` compares equal to `[lib]`.
195fn normalize_key(key: &str) -> String {
196    key.split('.')
197        .map(|segment| {
198            let segment = segment.trim();
199            segment
200                .strip_prefix('"')
201                .and_then(|s| s.strip_suffix('"'))
202                .or_else(|| {
203                    segment
204                        .strip_prefix('\'')
205                        .and_then(|s| s.strip_suffix('\''))
206                })
207                .unwrap_or(segment)
208        })
209        .collect::<Vec<_>>()
210        .join(".")
211}
212
213/// Parse a single-line TOML basic (`"..."`) or literal (`'...'`) string,
214/// allowing a trailing `# comment`. Escape sequences in a basic string,
215/// multi-line strings and non-string values return `None`. Unlike the macro
216/// crate's copy, a literal string keeps its backslashes (TOML has no escapes
217/// there), so `path = 'rust\lib.rs'` reads as written.
218fn parse_string_value(value: &str) -> Option<String> {
219    let (quote, rest) = match value.chars().next()? {
220        c @ ('"' | '\'') => (c, &value[1..]),
221        _ => return None,
222    };
223    if rest.starts_with(quote) && rest[1..].starts_with(quote) {
224        // `"""` / `'''`: multi-line strings are not supported.
225        return None;
226    }
227    let end = rest.find(quote)?;
228    let body = &rest[..end];
229    if body.contains('\\') && quote == '"' {
230        return None;
231    }
232    let trailing = rest[end + 1..].trim_start();
233    if !(trailing.is_empty() || trailing.starts_with('#')) {
234        return None;
235    }
236    Some(body.to_string())
237}