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