Skip to main content

pedant_core/
lint.rs

1use std::fs;
2use std::path::{Component, Path, PathBuf};
3
4use pedant_types::{AnalysisTier, ExecutionContext};
5
6use crate::analysis_result::AnalysisResult;
7use crate::capabilities::detect_capabilities;
8use crate::check_config::CheckConfig;
9use crate::ir;
10use crate::ir::DataFlowFact;
11use crate::ir::extract::{compute_fingerprints, parse_source};
12use crate::ir::semantic::SemanticContext;
13use crate::project::{FileShape, project_shape};
14use crate::style::check_style;
15
16struct ManifestPresence {
17    workspace: bool,
18    package: bool,
19    workspace_pointer: Option<Box<str>>,
20}
21
22/// Failure modes for the lint pipeline (I/O, parse, config).
23#[derive(Debug, thiserror::Error)]
24pub enum LintError {
25    /// Disk I/O failure reading source or config.
26    #[error("IO error: {0}")]
27    IoError(#[from] std::io::Error),
28    /// `syn` could not parse the Rust source.
29    #[error("parse error: {0}")]
30    ParseError(#[from] syn::Error),
31    /// TOML syntax or schema error in a config file.
32    #[error("TOML parse error: {0}")]
33    TomlParseError(#[from] toml::de::Error),
34    /// A package's explicit workspace manifest could not be read.
35    #[error("failed to read pointed workspace manifest {path}: {source}")]
36    PointedWorkspaceRead {
37        /// The `Cargo.toml` selected by `package.workspace`.
38        path: PathBuf,
39        /// The filesystem failure.
40        #[source]
41        source: std::io::Error,
42    },
43    /// A package's explicit workspace manifest is not valid TOML.
44    #[error("failed to parse pointed workspace manifest {path}: {source}")]
45    PointedWorkspaceParse {
46        /// The `Cargo.toml` selected by `package.workspace`.
47        path: PathBuf,
48        /// The TOML parser failure.
49        #[source]
50        source: toml::de::Error,
51    },
52    /// A package's explicit workspace manifest lacks its required table.
53    #[error("pointed workspace manifest {path} does not declare [workspace]")]
54    PointedWorkspaceDeclarationMissing {
55        /// The `Cargo.toml` selected by `package.workspace`.
56        path: PathBuf,
57    },
58    /// A package's explicit workspace pointer is not a TOML string.
59    #[error("package.workspace in {path} must be a string")]
60    WorkspacePointerNotString {
61        /// The package manifest holding the invalid pointer.
62        path: PathBuf,
63    },
64    /// One manifest cannot be both the local workspace and point elsewhere.
65    #[error("manifest {path} declares both [workspace] and package.workspace")]
66    WorkspacePointerWithLocalWorkspace {
67        /// The contradictory package manifest.
68        path: PathBuf,
69    },
70}
71
72/// Full analysis pipeline: parse, extract IR, run style checks, detect capabilities.
73///
74/// When `semantic` is `Some`, IR facts are enriched with resolved type information
75/// before checks run.
76pub fn analyze(
77    file_path: &str,
78    source: &str,
79    config: &CheckConfig,
80    semantic: Option<&SemanticContext>,
81) -> Result<AnalysisResult, syn::Error> {
82    Ok(analyze_inner(file_path, source, config, semantic, None)?.0)
83}
84
85/// Like [`analyze`], but tags all capability findings with `ExecutionContext::BuildHook`.
86pub fn analyze_build_script(
87    file_path: &str,
88    source: &str,
89    config: &CheckConfig,
90    semantic: Option<&SemanticContext>,
91) -> Result<AnalysisResult, syn::Error> {
92    Ok(analyze_inner(
93        file_path,
94        source,
95        config,
96        semantic,
97        Some(ExecutionContext::BuildHook),
98    )?
99    .0)
100}
101
102/// [`analyze`], plus the [`FileShape`] that whole-crate checks consume.
103///
104/// [`analyze`] discards the shape. Callers that also run
105/// [`check_project`](crate::project::check_project) take it from here, since
106/// projecting it later would mean parsing the file a second time.
107pub fn analyze_with_shape(
108    file_path: &str,
109    source: &str,
110    config: &CheckConfig,
111    semantic: Option<&SemanticContext>,
112) -> Result<(AnalysisResult, FileShape), syn::Error> {
113    analyze_inner(file_path, source, config, semantic, None)
114}
115
116/// [`analyze_build_script`], plus the [`FileShape`] whole-crate checks consume.
117pub fn analyze_build_script_with_shape(
118    file_path: &str,
119    source: &str,
120    config: &CheckConfig,
121    semantic: Option<&SemanticContext>,
122) -> Result<(AnalysisResult, FileShape), syn::Error> {
123    analyze_inner(
124        file_path,
125        source,
126        config,
127        semantic,
128        Some(ExecutionContext::BuildHook),
129    )
130}
131
132fn analyze_inner(
133    file_path: &str,
134    source: &str,
135    config: &CheckConfig,
136    semantic: Option<&SemanticContext>,
137    execution_context: Option<ExecutionContext>,
138) -> Result<(AnalysisResult, FileShape), syn::Error> {
139    let syntax = parse_source(file_path, source)?;
140    let mut ir = ir::extract(file_path, &syntax, semantic);
141    ir.source_line_count = source.lines().count();
142    let violations = check_style(&ir, config).into_boxed_slice();
143    let shape = project_shape(&ir, config);
144    let capabilities = detect_capabilities(&ir, execution_context);
145
146    #[cfg(feature = "semantic")]
147    let capabilities = {
148        let mut caps = capabilities;
149        if let Some(ctx) = semantic {
150            enrich_reachability(&mut caps.findings, ctx);
151        }
152        caps
153    };
154
155    let fn_fingerprints = compute_fingerprints(&ir);
156
157    Ok((
158        AnalysisResult {
159            violations,
160            capabilities,
161            data_flows: ir.data_flows,
162            fn_fingerprints,
163        },
164        shape,
165    ))
166}
167
168/// Convenience wrapper: analyze a Rust source string with no file path or semantic context.
169pub fn lint_str(source: &str, config: &CheckConfig) -> Result<AnalysisResult, LintError> {
170    analyze("<string>", source, config, None).map_err(LintError::from)
171}
172
173/// Convenience wrapper: read and analyze a Rust source file with no semantic context.
174pub fn lint_file(path: &Path, config: &CheckConfig) -> Result<AnalysisResult, LintError> {
175    let source = fs::read_to_string(path)?;
176    let file_path = path.to_string_lossy();
177    analyze(&file_path, &source, config, None).map_err(LintError::from)
178}
179
180/// Walk ancestors of `start` looking for a Cargo workspace or package root.
181///
182/// An explicit `package.workspace` selects that workspace before an ancestor
183/// can be considered. Otherwise, a directory containing a `Cargo.toml` with
184/// `[workspace]` is preferred, with the nearest package as the fallback.
185/// Returns an error when a manifest exists but cannot be read or when an
186/// explicit workspace pointer cannot identify a workspace manifest.
187pub fn discover_workspace_root(start: &Path) -> Result<Option<PathBuf>, LintError> {
188    let start_dir = match (start.is_dir(), start.parent()) {
189        (true, _) => start,
190        (false, Some(parent)) => parent,
191        (false, None) => return Ok(None),
192    };
193
194    let mut nearest_package: Option<PathBuf> = None;
195    for dir in start_dir.ancestors() {
196        let cargo_toml = dir.join("Cargo.toml");
197        let presence = read_manifest_presence(&cargo_toml)?;
198        match (
199            presence.workspace,
200            presence.package,
201            nearest_package.is_some(),
202            presence.workspace_pointer,
203        ) {
204            (true, _, _, _) => return Ok(Some(dir.to_path_buf())),
205            (false, true, false, Some(pointer)) => {
206                return pointed_workspace_root(dir, &pointer).map(Some);
207            }
208            (false, true, false, None) => nearest_package = Some(dir.to_path_buf()),
209            _ => {}
210        }
211    }
212    Ok(nearest_package)
213}
214
215/// Walk ancestors of `file` to the nearest directory holding a `Cargo.toml`.
216///
217/// That directory is the file's crate root: Cargo resolves a source file's
218/// package by exactly this rule. Returns `None` for a file with no manifest
219/// above it, which has no crate to belong to.
220pub fn discover_crate_root(file: &Path) -> Option<&Path> {
221    let mut dir = file.parent()?;
222    loop {
223        match dir.join("Cargo.toml").is_file() {
224            true => return Some(dir),
225            false => dir = dir.parent()?,
226        }
227    }
228}
229
230fn read_manifest_presence(cargo_toml: &Path) -> Result<ManifestPresence, LintError> {
231    let contents = match fs::read_to_string(cargo_toml) {
232        Ok(contents) => contents,
233        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
234            return Ok(ManifestPresence {
235                workspace: false,
236                package: false,
237                workspace_pointer: None,
238            });
239        }
240        Err(error) => return Err(LintError::IoError(error)),
241    };
242    let table: toml::Table = contents.parse()?;
243    let package = table.get("package").and_then(toml::Value::as_table);
244    let workspace_pointer = match package.and_then(|package| package.get("workspace")) {
245        Some(toml::Value::String(pointer)) => Some(Box::from(pointer.as_str())),
246        Some(_) => {
247            return Err(LintError::WorkspacePointerNotString {
248                path: cargo_toml.to_path_buf(),
249            });
250        }
251        None => None,
252    };
253    let workspace = contains_manifest_table(&table, "workspace");
254    if let (true, true) = (workspace, workspace_pointer.is_some()) {
255        return Err(LintError::WorkspacePointerWithLocalWorkspace {
256            path: cargo_toml.to_path_buf(),
257        });
258    }
259    Ok(ManifestPresence {
260        workspace,
261        package: package.is_some(),
262        workspace_pointer,
263    })
264}
265
266fn pointed_workspace_root(package_root: &Path, pointer: &str) -> Result<PathBuf, LintError> {
267    let workspace_root = lexical_path(package_root.join(pointer));
268    let manifest = workspace_root.join("Cargo.toml");
269    let contents =
270        fs::read_to_string(&manifest).map_err(|source| LintError::PointedWorkspaceRead {
271            path: manifest.clone(),
272            source,
273        })?;
274    let table =
275        contents
276            .parse::<toml::Table>()
277            .map_err(|source| LintError::PointedWorkspaceParse {
278                path: manifest.clone(),
279                source,
280            })?;
281    match contains_manifest_table(&table, "workspace") {
282        true => Ok(workspace_root),
283        false => Err(LintError::PointedWorkspaceDeclarationMissing { path: manifest }),
284    }
285}
286
287fn lexical_path(path: PathBuf) -> PathBuf {
288    let rooted = path.has_root();
289    path.components().fold(PathBuf::new(), |mut result, part| {
290        match part {
291            Component::ParentDir => resolve_parent(&mut result, rooted),
292            Component::CurDir => {}
293            _ => result.push(part.as_os_str()),
294        }
295        result
296    })
297}
298
299fn resolve_parent(path: &mut PathBuf, rooted: bool) {
300    match path.components().next_back() {
301        Some(Component::Normal(_)) => {
302            path.pop();
303        }
304        Some(Component::ParentDir) | None if !rooted => path.push(".."),
305        _ => {}
306    }
307}
308
309fn contains_manifest_table(table: &toml::Table, section_name: &str) -> bool {
310    table
311        .get(section_name)
312        .and_then(toml::Value::as_table)
313        .is_some()
314}
315
316/// Find the build script for a crate by reading `[package].build` from `Cargo.toml`.
317///
318/// Falls back to `build.rs` when `build` is not specified.
319/// Returns `Ok(None)` when no `Cargo.toml` or build script exists on disk.
320pub fn discover_build_script(crate_root: &Path) -> Result<Option<PathBuf>, LintError> {
321    let cargo_toml_path = crate_root.join("Cargo.toml");
322    let cargo_toml_contents = match fs::read_to_string(&cargo_toml_path) {
323        Ok(contents) => contents,
324        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
325        Err(e) => return Err(LintError::IoError(e)),
326    };
327    let table: toml::Table = cargo_toml_contents.parse()?;
328
329    let custom_path = table
330        .get("package")
331        .and_then(toml::Value::as_table)
332        .and_then(|pkg| pkg.get("build"))
333        .and_then(toml::Value::as_str);
334
335    let candidate = match custom_path {
336        Some(build_path) => crate_root.join(build_path),
337        None => crate_root.join("build.rs"),
338    };
339
340    Ok(candidate.is_file().then_some(candidate))
341}
342
343/// Analyze a source file and optionally merge build-script capability findings.
344///
345/// When `build_source` is `Some`, its findings are tagged with
346/// `ExecutionContext::BuildHook` and appended to the main result's capability profile.
347pub fn analyze_with_build_script(
348    file_path: &str,
349    source: &str,
350    config: &CheckConfig,
351    semantic: Option<&SemanticContext>,
352    build_source: Option<(&str, &str)>,
353) -> Result<AnalysisResult, syn::Error> {
354    let mut result = analyze(file_path, source, config, semantic)?;
355
356    let Some((build_path, build_src)) = build_source else {
357        return Ok(result);
358    };
359
360    let build_caps = analyze_build_script(build_path, build_src, config, semantic)?.capabilities;
361
362    let mut merged = result.capabilities.findings.into_vec();
363    merged.extend(build_caps.findings);
364    result.capabilities.findings = merged.into_boxed_slice();
365
366    Ok(result)
367}
368
369/// Determine the analysis tier based on whether semantic analysis ran and
370/// whether data flow facts were detected.
371///
372/// - `DataFlow` when semantic context was active and flows were found.
373/// - `Semantic` when semantic context was active but no flows detected.
374/// - `Syntactic` otherwise.
375pub fn determine_analysis_tier(
376    semantic: Option<&SemanticContext>,
377    data_flows: &[DataFlowFact],
378) -> AnalysisTier {
379    match (semantic.is_some(), !data_flows.is_empty()) {
380        (true, true) => AnalysisTier::DataFlow,
381        (true, false) => AnalysisTier::Semantic,
382        (false, _) => AnalysisTier::Syntactic,
383    }
384}
385
386/// Annotate capability findings with entry-point reachability.
387///
388/// Sets `reachable` to `Some(true)` or `Some(false)` for each finding
389/// based on whether the containing function is reachable from a public
390/// entry point via the call graph.
391#[cfg(feature = "semantic")]
392fn enrich_reachability(findings: &mut [pedant_types::CapabilityFinding], ctx: &SemanticContext) {
393    use std::collections::BTreeMap;
394    use std::sync::Arc;
395
396    // Group finding indices by file so the call graph is built once per file.
397    let mut by_file: BTreeMap<Arc<str>, Vec<usize>> = BTreeMap::new();
398    for (idx, finding) in findings.iter().enumerate() {
399        by_file
400            .entry(Arc::clone(&finding.location.file))
401            .or_default()
402            .push(idx);
403    }
404
405    for (file, indices) in &by_file {
406        let Some(analysis) = ctx.analyze_file(file) else {
407            continue;
408        };
409        let lines: Vec<usize> = indices.iter().map(|&i| findings[i].location.line).collect();
410        let results = analysis.check_reachability_batch(&lines);
411        for (pos, &idx) in indices.iter().enumerate() {
412            findings[idx].reachable = Some(results[pos]);
413        }
414    }
415}