Skip to main content

pedant_core/
lint.rs

1use std::fs;
2use std::path::{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;
12use crate::ir::semantic::SemanticContext;
13use crate::project::{FileShape, project_shape};
14use crate::style::check_style;
15
16type ManifestPresence = (bool, bool);
17
18/// Failure modes for the lint pipeline (I/O, parse, config).
19#[derive(Debug, thiserror::Error)]
20pub enum LintError {
21    /// Disk I/O failure reading source or config.
22    #[error("IO error: {0}")]
23    IoError(#[from] std::io::Error),
24    /// `syn` could not parse the Rust source.
25    #[error("parse error: {0}")]
26    ParseError(#[from] syn::Error),
27    /// TOML syntax or schema error in a config file.
28    #[error("TOML parse error: {0}")]
29    TomlParseError(#[from] toml::de::Error),
30}
31
32/// Full analysis pipeline: parse, extract IR, run style checks, detect capabilities.
33///
34/// When `semantic` is `Some`, IR facts are enriched with resolved type information
35/// before checks run.
36pub fn analyze(
37    file_path: &str,
38    source: &str,
39    config: &CheckConfig,
40    semantic: Option<&SemanticContext>,
41) -> Result<AnalysisResult, syn::Error> {
42    Ok(analyze_inner(file_path, source, config, semantic, None)?.0)
43}
44
45/// Like [`analyze`], but tags all capability findings with `ExecutionContext::BuildHook`.
46pub fn analyze_build_script(
47    file_path: &str,
48    source: &str,
49    config: &CheckConfig,
50    semantic: Option<&SemanticContext>,
51) -> Result<AnalysisResult, syn::Error> {
52    Ok(analyze_inner(
53        file_path,
54        source,
55        config,
56        semantic,
57        Some(ExecutionContext::BuildHook),
58    )?
59    .0)
60}
61
62/// [`analyze`], plus the [`FileShape`] that whole-crate checks consume.
63///
64/// [`analyze`] discards the shape. Callers that also run
65/// [`check_project`](crate::project::check_project) take it from here, since
66/// projecting it later would mean parsing the file a second time.
67pub fn analyze_with_shape(
68    file_path: &str,
69    source: &str,
70    config: &CheckConfig,
71    semantic: Option<&SemanticContext>,
72) -> Result<(AnalysisResult, FileShape), syn::Error> {
73    analyze_inner(file_path, source, config, semantic, None)
74}
75
76/// [`analyze_build_script`], plus the [`FileShape`] whole-crate checks consume.
77pub fn analyze_build_script_with_shape(
78    file_path: &str,
79    source: &str,
80    config: &CheckConfig,
81    semantic: Option<&SemanticContext>,
82) -> Result<(AnalysisResult, FileShape), syn::Error> {
83    analyze_inner(
84        file_path,
85        source,
86        config,
87        semantic,
88        Some(ExecutionContext::BuildHook),
89    )
90}
91
92fn analyze_inner(
93    file_path: &str,
94    source: &str,
95    config: &CheckConfig,
96    semantic: Option<&SemanticContext>,
97    execution_context: Option<ExecutionContext>,
98) -> Result<(AnalysisResult, FileShape), syn::Error> {
99    let syntax = syn::parse_file(source)?;
100    let mut ir = ir::extract(file_path, &syntax, semantic);
101    ir.source_line_count = source.lines().count();
102    let violations = check_style(&ir, config).into_boxed_slice();
103    let shape = project_shape(&ir, config);
104    let capabilities = detect_capabilities(&ir, execution_context);
105
106    #[cfg(feature = "semantic")]
107    let capabilities = {
108        let mut caps = capabilities;
109        if let Some(ctx) = semantic {
110            enrich_reachability(&mut caps.findings, ctx);
111        }
112        caps
113    };
114
115    let fn_fingerprints = compute_fingerprints(&ir);
116
117    Ok((
118        AnalysisResult {
119            violations,
120            capabilities,
121            data_flows: ir.data_flows,
122            fn_fingerprints,
123        },
124        shape,
125    ))
126}
127
128/// Convenience wrapper: analyze a Rust source string with no file path or semantic context.
129pub fn lint_str(source: &str, config: &CheckConfig) -> Result<AnalysisResult, LintError> {
130    analyze("<string>", source, config, None).map_err(LintError::from)
131}
132
133/// Convenience wrapper: read and analyze a Rust source file with no semantic context.
134pub fn lint_file(path: &Path, config: &CheckConfig) -> Result<AnalysisResult, LintError> {
135    let source = fs::read_to_string(path)?;
136    let file_path = path.to_string_lossy();
137    analyze(&file_path, &source, config, None).map_err(LintError::from)
138}
139
140/// Walk ancestors of `start` looking for a Cargo workspace or package root.
141///
142/// Prefers a directory containing a `Cargo.toml` with `[workspace]`. Falls back
143/// to the nearest `Cargo.toml` with `[package]` if no workspace is found.
144/// Returns an error when a manifest exists but cannot be read.
145pub fn discover_workspace_root(start: &Path) -> Result<Option<PathBuf>, LintError> {
146    let start_dir = match (start.is_dir(), start.parent()) {
147        (true, _) => start,
148        (false, Some(parent)) => parent,
149        (false, None) => return Ok(None),
150    };
151
152    let mut nearest_package: Option<PathBuf> = None;
153    for dir in start_dir.ancestors() {
154        let cargo_toml = dir.join("Cargo.toml");
155        let (has_workspace, has_package) = read_manifest_presence(&cargo_toml)?;
156        match (has_workspace, has_package, nearest_package.is_some()) {
157            (true, _, _) => return Ok(Some(dir.to_path_buf())),
158            (false, true, false) => nearest_package = Some(dir.to_path_buf()),
159            _ => {}
160        }
161    }
162    Ok(nearest_package)
163}
164
165/// Walk ancestors of `file` to the nearest directory holding a `Cargo.toml`.
166///
167/// That directory is the file's crate root: Cargo resolves a source file's
168/// package by exactly this rule. Returns `None` for a file with no manifest
169/// above it, which has no crate to belong to.
170pub fn discover_crate_root(file: &Path) -> Option<&Path> {
171    let mut dir = file.parent()?;
172    loop {
173        match dir.join("Cargo.toml").is_file() {
174            true => return Some(dir),
175            false => dir = dir.parent()?,
176        }
177    }
178}
179
180fn read_manifest_presence(cargo_toml: &Path) -> Result<ManifestPresence, LintError> {
181    let contents = match fs::read_to_string(cargo_toml) {
182        Ok(contents) => contents,
183        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((false, false)),
184        Err(error) => return Err(LintError::IoError(error)),
185    };
186    let table: toml::Table = contents.parse()?;
187    Ok((
188        contains_manifest_table(&table, "workspace"),
189        contains_manifest_table(&table, "package"),
190    ))
191}
192
193fn contains_manifest_table(table: &toml::Table, section_name: &str) -> bool {
194    table
195        .get(section_name)
196        .and_then(toml::Value::as_table)
197        .is_some()
198}
199
200/// Find the build script for a crate by reading `[package].build` from `Cargo.toml`.
201///
202/// Falls back to `build.rs` when `build` is not specified.
203/// Returns `Ok(None)` when no `Cargo.toml` or build script exists on disk.
204pub fn discover_build_script(crate_root: &Path) -> Result<Option<PathBuf>, LintError> {
205    let cargo_toml_path = crate_root.join("Cargo.toml");
206    let cargo_toml_contents = match fs::read_to_string(&cargo_toml_path) {
207        Ok(contents) => contents,
208        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
209        Err(e) => return Err(LintError::IoError(e)),
210    };
211    let table: toml::Table = cargo_toml_contents.parse()?;
212
213    let custom_path = table
214        .get("package")
215        .and_then(toml::Value::as_table)
216        .and_then(|pkg| pkg.get("build"))
217        .and_then(toml::Value::as_str);
218
219    let candidate = match custom_path {
220        Some(build_path) => crate_root.join(build_path),
221        None => crate_root.join("build.rs"),
222    };
223
224    Ok(candidate.is_file().then_some(candidate))
225}
226
227/// Analyze a source file and optionally merge build-script capability findings.
228///
229/// When `build_source` is `Some`, its findings are tagged with
230/// `ExecutionContext::BuildHook` and appended to the main result's capability profile.
231pub fn analyze_with_build_script(
232    file_path: &str,
233    source: &str,
234    config: &CheckConfig,
235    semantic: Option<&SemanticContext>,
236    build_source: Option<(&str, &str)>,
237) -> Result<AnalysisResult, syn::Error> {
238    let mut result = analyze(file_path, source, config, semantic)?;
239
240    let Some((build_path, build_src)) = build_source else {
241        return Ok(result);
242    };
243
244    let build_caps = analyze_build_script(build_path, build_src, config, semantic)?.capabilities;
245
246    let mut merged = result.capabilities.findings.into_vec();
247    merged.extend(build_caps.findings);
248    result.capabilities.findings = merged.into_boxed_slice();
249
250    Ok(result)
251}
252
253/// Determine the analysis tier based on whether semantic analysis ran and
254/// whether data flow facts were detected.
255///
256/// - `DataFlow` when semantic context was active and flows were found.
257/// - `Semantic` when semantic context was active but no flows detected.
258/// - `Syntactic` otherwise.
259pub fn determine_analysis_tier(
260    semantic: Option<&SemanticContext>,
261    data_flows: &[DataFlowFact],
262) -> AnalysisTier {
263    match (semantic.is_some(), !data_flows.is_empty()) {
264        (true, true) => AnalysisTier::DataFlow,
265        (true, false) => AnalysisTier::Semantic,
266        (false, _) => AnalysisTier::Syntactic,
267    }
268}
269
270/// Annotate capability findings with entry-point reachability.
271///
272/// Sets `reachable` to `Some(true)` or `Some(false)` for each finding
273/// based on whether the containing function is reachable from a public
274/// entry point via the call graph.
275#[cfg(feature = "semantic")]
276fn enrich_reachability(findings: &mut [pedant_types::CapabilityFinding], ctx: &SemanticContext) {
277    use std::collections::BTreeMap;
278    use std::sync::Arc;
279
280    // Group finding indices by file so the call graph is built once per file.
281    let mut by_file: BTreeMap<Arc<str>, Vec<usize>> = BTreeMap::new();
282    for (idx, finding) in findings.iter().enumerate() {
283        by_file
284            .entry(Arc::clone(&finding.location.file))
285            .or_default()
286            .push(idx);
287    }
288
289    for (file, indices) in &by_file {
290        let Some(analysis) = ctx.analyze_file(file) else {
291            continue;
292        };
293        let lines: Vec<usize> = indices.iter().map(|&i| findings[i].location.line).collect();
294        let results = analysis.check_reachability_batch(&lines);
295        for (pos, &idx) in indices.iter().enumerate() {
296            findings[idx].reachable = Some(results[pos]);
297        }
298    }
299}