1use std::collections::BTreeMap;
2use std::collections::BTreeSet;
3use std::fmt;
4use std::fs;
5use std::path::Path;
6use std::path::PathBuf;
7use std::sync::OnceLock;
8
9use anyhow as anyhow_crate;
10use anyhow::Context;
11use anyhow::Result;
12use cargo_metadata::MetadataCommand;
13use quote::ToTokens;
14use sc_lint_directives::AttributeInput;
15use sc_lint_directives::Directive;
16pub use sc_lint_schema::CrateId;
17use sc_lint_schema::NodeId;
18use sc_lint_schema::OutputFormat;
19use sc_lint_schema::OwnerId;
20use sc_lint_schema::ReportStatus;
21use serde::Deserialize;
22use serde::Serialize;
23use serde::Serializer;
24use syn::Attribute;
25use syn::File;
26use syn::Ident;
27use syn::ImplItem;
28use syn::Item;
29use syn::Receiver;
30use syn::Type;
31use syn::visit::Visit;
32use thiserror::Error;
33
34mod analysis;
35mod graph;
36mod inventory;
37mod manifest_policy;
38mod render;
39#[cfg(test)]
40mod tests;
41
42const SC_LINT_SCHEMA_VERSION: &str = "0.1.0";
43const DEFAULT_RULES_TOML: &str = include_str!("../config/defaults.toml");
44const SC_LINT_BOUNDARY_TOOL: &str = "sc-lint-boundary";
45const SC_LINT_BOUNDARY_VERSION: &str = env!("CARGO_PKG_VERSION");
46
47#[derive(Debug, Clone, PartialEq, Eq, Error)]
48#[error("{0}")]
49pub struct BoundaryErrorSource(Box<str>);
50
51impl From<anyhow_crate::Error> for BoundaryErrorSource {
52 fn from(value: anyhow_crate::Error) -> Self {
53 Self(format!("{value:#}").into_boxed_str())
54 }
55}
56
57#[derive(Debug, Error)]
58pub enum BoundaryError {
59 #[error("failed to load boundary inventory for root `{}`: {source:#}", root.display())]
60 InventoryLoad {
61 root: PathBuf,
62 #[source]
63 source: BoundaryErrorSource,
64 },
65 #[error("failed to analyze manifest policy for root `{}`: {source:#}", root.display())]
66 ManifestPolicyAnalysis {
67 root: PathBuf,
68 #[source]
69 source: BoundaryErrorSource,
70 },
71 #[error("failed to build workspace graph for root `{}`: {source:#}", root.display())]
72 WorkspaceGraphBuild {
73 root: PathBuf,
74 #[source]
75 source: BoundaryErrorSource,
76 },
77}
78
79#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
80struct RuleDefaults {
81 trait_self_loop: TraitSelfLoopDefaults,
82}
83
84#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
85struct TraitSelfLoopDefaults {
86 ignored_trait_paths: Vec<String>,
87 ignored_trait_names: Vec<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct AnalyzeOptions {
92 pub root: PathBuf,
93 pub format: OutputFormat,
94 pub rule: Option<RuleFilter>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct ExportGraphOptions {
99 pub root: PathBuf,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum GraphOutputFormat {
104 Json,
105 Turtle,
106}
107
108pub type FindingsReport = sc_lint_schema::FindingsReport<RuleId>;
109pub type Finding = sc_lint_schema::Finding<RuleId>;
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
112#[non_exhaustive]
113pub enum RuleId {
114 ScbCycle001,
115 ScbCycle002,
116 ScbCycle003,
117 ScbBoundary001,
118 ScbBoundary002,
119 ScbBoundary003,
120 ScbCaller001,
121 ScbManifest001,
122 ScbManifest002,
123}
124
125impl RuleId {
126 pub const fn as_str(self) -> &'static str {
127 match self {
128 Self::ScbCycle001 => "SCB-CYCLE-001",
129 Self::ScbCycle002 => "SCB-CYCLE-002",
130 Self::ScbCycle003 => "SCB-CYCLE-003",
131 Self::ScbBoundary001 => "SCB-BOUNDARY-001",
132 Self::ScbBoundary002 => "SCB-BOUNDARY-002",
133 Self::ScbBoundary003 => "SCB-BOUNDARY-003",
134 Self::ScbCaller001 => "SCB-CALLER-001",
135 Self::ScbManifest001 => "SCB-MANIFEST-001",
136 Self::ScbManifest002 => "SCB-MANIFEST-002",
137 }
138 }
139}
140
141impl Serialize for RuleId {
142 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
143 where
144 S: Serializer,
145 {
146 serializer.serialize_str(self.as_str())
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum RuleFilter {
152 Cycles,
153 Boundaries,
157 InternalOnly,
158 ForbidExternalImpls,
159 Manifests,
160}
161
162impl RuleFilter {
163 pub const fn as_str(self) -> &'static str {
164 match self {
165 Self::Cycles => "cycles",
166 Self::Boundaries => "boundaries",
167 Self::InternalOnly => "internal_only",
168 Self::ForbidExternalImpls => "forbid_external_impls",
169 Self::Manifests => "manifests",
170 }
171 }
172}
173
174impl fmt::Display for RuleFilter {
175 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176 f.write_str(self.as_str())
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct RuleFilterParseError {
182 invalid_value: String,
183}
184
185impl RuleFilterParseError {
186 fn new(invalid_value: impl Into<String>) -> Self {
187 Self {
188 invalid_value: invalid_value.into(),
189 }
190 }
191}
192
193impl fmt::Display for RuleFilterParseError {
194 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195 write!(
196 f,
197 "unsupported rule filter `{}`; supported: cycles, boundaries, internal_only, forbid_external_impls, manifests",
198 self.invalid_value
199 )
200 }
201}
202
203impl std::error::Error for RuleFilterParseError {}
204
205impl TryFrom<&str> for RuleFilter {
206 type Error = RuleFilterParseError;
207
208 fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
209 match value {
210 "cycles" => Ok(Self::Cycles),
211 "boundaries" => Ok(Self::Boundaries),
212 "internal_only" => Ok(Self::InternalOnly),
213 "forbid_external_impls" => Ok(Self::ForbidExternalImpls),
214 "manifests" => Ok(Self::Manifests),
215 other => Err(RuleFilterParseError::new(other)),
216 }
217 }
218}
219
220#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
221pub struct GraphExport {
222 pub tool: &'static str,
223 pub version: &'static str,
224 pub schema_version: &'static str,
225 pub nodes: Vec<GraphNode>,
226 pub edges: Vec<GraphEdge>,
227}
228
229#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
230pub struct GraphNode {
231 pub id: NodeId,
232 pub kind: &'static str,
233 pub label: String,
234 pub visibility: Option<&'static str>,
235 pub package: String,
236 pub target: Option<String>,
237 pub manifest_path: String,
238 pub source_path: Option<String>,
239 pub module_path: Option<String>,
240 pub impl_kind: Option<ImplKind>,
241 pub impl_trait: Option<String>,
242 pub attributes: Vec<LintAttribute>,
243}
244
245#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
246pub struct GraphEdge {
247 pub kind: &'static str,
248 pub from: NodeId,
249 pub to: NodeId,
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
253enum ReferenceKind {
254 Type,
255 Expr,
256}
257
258impl ReferenceKind {
259 fn edge_kind(self) -> &'static str {
260 match self {
261 Self::Type => "references_type",
262 Self::Expr => "references_expr",
263 }
264 }
265}
266
267#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
268#[serde(rename_all = "snake_case")]
269pub enum ImplKind {
270 Trait,
271 Inherent,
272}
273
274impl ImplKind {
275 pub const fn as_str(self) -> &'static str {
276 match self {
277 Self::Trait => "trait",
278 Self::Inherent => "inherent",
279 }
280 }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
284struct CollectedReference {
285 path: String,
286 kind: ReferenceKind,
287}
288
289#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
290pub struct LintAttribute {
291 pub scope: &'static str,
292 pub name: &'static str,
293 pub values: Vec<String>,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq)]
297struct ModulePath(String);
298
299impl ModulePath {
300 fn crate_root() -> Self {
301 Self("crate".to_string())
302 }
303
304 fn child(&self, name: &str) -> Self {
305 Self(format!("{}::{name}", self.0))
306 }
307
308 fn as_str(&self) -> &str {
309 &self.0
310 }
311}
312
313impl AsRef<str> for ModulePath {
314 fn as_ref(&self) -> &str {
315 self.as_str()
316 }
317}
318
319impl fmt::Display for ModulePath {
320 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
321 formatter.write_str(self.as_str())
322 }
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
326struct TargetContext {
327 package_name: String,
328 target_name: String,
329 manifest_path: String,
330 crate_id: CrateId,
331 root_module_path: ModulePath,
332 workspace_dependency_roots: BTreeMap<String, CrateId>,
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336enum ItemVisibility {
337 Private,
338 Public,
339 Crate,
340 Restricted,
341}
342
343impl ItemVisibility {
344 fn as_str(self) -> &'static str {
345 match self {
346 Self::Private => "private",
347 Self::Public => "public",
348 Self::Crate => "crate",
349 Self::Restricted => "restricted",
350 }
351 }
352}
353
354#[derive(Debug, Default)]
355struct GraphBuilder {
356 nodes: Vec<GraphNode>,
357 edges: Vec<GraphEdge>,
358}
359
360impl GraphBuilder {
361 fn add_node(&mut self, node: GraphNode) {
362 debug_assert!(
363 !node.package.is_empty(),
364 "graph nodes must carry a non-empty package name"
365 );
366 if !self.nodes.iter().any(|existing| existing.id == node.id) {
367 self.nodes.push(node);
368 }
369 }
370
371 fn add_edge(&mut self, kind: &'static str, from: impl Into<NodeId>, to: impl Into<NodeId>) {
372 let edge = GraphEdge {
373 kind,
374 from: from.into(),
375 to: to.into(),
376 };
377 if !self.edges.contains(&edge) {
378 self.edges.push(edge);
379 }
380 }
381
382 fn add_workspace_target(
383 &mut self,
384 package_name: &str,
385 manifest_path: &str,
386 target_name: &str,
387 source_path: &Path,
388 ) {
389 let crate_id = graph::crate_id(package_name, target_name);
390 self.add_node(GraphNode {
391 id: NodeId::new(String::from(crate_id)),
392 kind: "crate",
393 label: target_name.to_string(),
394 visibility: None,
395 package: package_name.to_string(),
396 target: Some(target_name.to_string()),
397 manifest_path: manifest_path.to_string(),
398 source_path: Some(source_path.display().to_string()),
399 module_path: Some("crate".to_string()),
400 impl_kind: None,
401 impl_trait: None,
402 attributes: Vec::new(),
403 });
404 }
405
406 fn finish(mut self) -> GraphExport {
407 self.nodes.sort_by(|left, right| left.id.cmp(&right.id));
408 self.edges.sort_by(|left, right| {
409 left.kind
410 .cmp(right.kind)
411 .then_with(|| left.from.cmp(&right.from))
412 .then_with(|| left.to.cmp(&right.to))
413 });
414
415 GraphExport {
416 tool: SC_LINT_BOUNDARY_TOOL,
417 version: SC_LINT_BOUNDARY_VERSION,
418 schema_version: SC_LINT_SCHEMA_VERSION,
419 nodes: self.nodes,
420 edges: self.edges,
421 }
422 }
423}
424
425pub fn analyze_workspace(
426 options: &AnalyzeOptions,
427) -> std::result::Result<FindingsReport, BoundaryError> {
428 if options.rule == Some(RuleFilter::Manifests) {
429 let manifest_report =
430 manifest_policy::analyze_manifest_policy(&options.root).map_err(|source| {
431 BoundaryError::ManifestPolicyAnalysis {
432 root: options.root.clone(),
433 source: source.into(),
434 }
435 })?;
436 let status = if manifest_report
437 .findings
438 .iter()
439 .any(analysis::finding_is_failure)
440 {
441 ReportStatus::Fail
442 } else {
443 ReportStatus::Pass
444 };
445 return Ok(FindingsReport {
446 tool: SC_LINT_BOUNDARY_TOOL,
447 version: SC_LINT_BOUNDARY_VERSION,
448 schema_version: SC_LINT_SCHEMA_VERSION,
449 status,
450 scanned_crates: manifest_report.scanned_crates,
451 findings: manifest_report.findings,
452 });
453 }
454
455 let inventory = inventory::load_boundary_inventory(&options.root).map_err(|source| {
456 BoundaryError::InventoryLoad {
457 root: options.root.clone(),
458 source: source.into(),
459 }
460 })?;
461 let graph = graph::build_workspace_graph(&options.root).map_err(|source| {
462 BoundaryError::WorkspaceGraphBuild {
463 root: options.root.clone(),
464 source: source.into(),
465 }
466 })?;
467 let inventory_summary = inventory.summary();
468 let mut findings = Vec::with_capacity(inventory_summary.recommended_finding_capacity());
469 let filter = options.rule;
470 if filter.is_none() || filter == Some(RuleFilter::Cycles) {
471 findings.extend(analysis::analyze_cycles(&graph));
472 }
473 if filter.is_none()
474 || filter == Some(RuleFilter::Boundaries)
475 || filter == Some(RuleFilter::InternalOnly)
476 {
477 findings.extend(analysis::analyze_internal_only(&graph));
478 }
479 if filter.is_none()
480 || filter == Some(RuleFilter::Boundaries)
481 || filter == Some(RuleFilter::ForbidExternalImpls)
482 {
483 findings.extend(analysis::analyze_forbid_external_impls(&graph));
484 }
485 if filter.is_none() || filter == Some(RuleFilter::Boundaries) {
486 findings.extend(analysis::analyze_named_callers(&graph, &inventory));
487 }
488 if filter.is_none() {
489 findings.extend(
490 manifest_policy::analyze_manifest_policy(&options.root)
491 .map_err(|source| BoundaryError::ManifestPolicyAnalysis {
492 root: options.root.clone(),
493 source: source.into(),
494 })?
495 .findings,
496 );
497 }
498 findings.sort_by(|left, right| {
499 analysis::finding_sort_key(left)
500 .cmp(&analysis::finding_sort_key(right))
501 .then_with(|| left.message.cmp(&right.message))
502 });
503 let scanned_crates = graph
504 .nodes
505 .iter()
506 .filter(|node| node.kind == "crate")
507 .count();
508 let status = if findings.iter().any(analysis::finding_is_failure) {
509 ReportStatus::Fail
510 } else {
511 ReportStatus::Pass
512 };
513
514 Ok(FindingsReport {
515 tool: SC_LINT_BOUNDARY_TOOL,
516 version: SC_LINT_BOUNDARY_VERSION,
517 schema_version: SC_LINT_SCHEMA_VERSION,
518 status,
519 scanned_crates,
520 findings,
521 })
522}
523
524pub fn export_workspace_graph(
525 options: &ExportGraphOptions,
526) -> std::result::Result<GraphExport, BoundaryError> {
527 graph::build_workspace_graph(&options.root).map_err(|source| {
528 BoundaryError::WorkspaceGraphBuild {
529 root: options.root.clone(),
530 source: source.into(),
531 }
532 })
533}
534
535pub fn render_findings_report(report: &FindingsReport) -> String {
536 render::render_findings_report(report)
537}
538
539pub fn render_graph_export(graph: &GraphExport, format: GraphOutputFormat) -> String {
540 render::render_graph_export(graph, format)
541}
542
543pub fn render_graph_export_json(graph: &GraphExport) -> String {
544 render::render_graph_export_json(graph)
545}
546
547pub fn render_graph_export_turtle(graph: &GraphExport) -> String {
548 render::render_graph_export_turtle(graph)
549}