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#[derive(Debug, thiserror::Error)]
24pub enum LintError {
25 #[error("IO error: {0}")]
27 IoError(#[from] std::io::Error),
28 #[error("parse error: {0}")]
30 ParseError(#[from] syn::Error),
31 #[error("TOML parse error: {0}")]
33 TomlParseError(#[from] toml::de::Error),
34 #[error("failed to read pointed workspace manifest {path}: {source}")]
36 PointedWorkspaceRead {
37 path: PathBuf,
39 #[source]
41 source: std::io::Error,
42 },
43 #[error("failed to parse pointed workspace manifest {path}: {source}")]
45 PointedWorkspaceParse {
46 path: PathBuf,
48 #[source]
50 source: toml::de::Error,
51 },
52 #[error("pointed workspace manifest {path} does not declare [workspace]")]
54 PointedWorkspaceDeclarationMissing {
55 path: PathBuf,
57 },
58 #[error("package.workspace in {path} must be a string")]
60 WorkspacePointerNotString {
61 path: PathBuf,
63 },
64 #[error("manifest {path} declares both [workspace] and package.workspace")]
66 WorkspacePointerWithLocalWorkspace {
67 path: PathBuf,
69 },
70}
71
72pub 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
85pub 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
102pub 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
116pub 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
168pub fn lint_str(source: &str, config: &CheckConfig) -> Result<AnalysisResult, LintError> {
170 analyze("<string>", source, config, None).map_err(LintError::from)
171}
172
173pub 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
180pub 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
215pub 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
316pub 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
343pub 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
369pub 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#[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 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}