Skip to main content

sc_lint_boundary/
lib.rs

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    // SCB-CALLER-001 is enforced as part of boundary policy, so it routes
154    // through the existing boundaries filter instead of a separate callers
155    // variant.
156    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}