Skip to main content

fallow_types/
trace_usage.rs

1//! Per-specifier usage of a dependency: the `usage` object of
2//! `fallow trace --dependency <package>`.
3//!
4//! The object counts how the code uses each imported name of a package. It
5//! follows one hop through a project wrapper, for example
6//! `export const useAppSelector = useSelector.withTypes<RootState>()`, and it
7//! counts each use that it cannot resolve. The answer is syntactic: it reads
8//! import bindings and call sites, not types.
9
10use serde::Serialize;
11
12/// Wire-shape version of the [`DependencyUsage`] object.
13///
14/// The object has its own version, because it is an optional part of the
15/// `DependencyTrace` envelope.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18pub enum DependencyUsageSchemaVersion {
19    /// First release of the dependency usage shape.
20    #[serde(rename = "1")]
21    V1,
22}
23
24/// How the usage was found.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[serde(rename_all = "snake_case")]
28pub enum UsageConfidence {
29    /// Read from import bindings and call sites, without type information.
30    Syntactic,
31}
32
33/// How the code uses each imported name of a dependency.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
36pub struct DependencyUsage {
37    /// Wire-shape version of this object.
38    pub schema_version: DependencyUsageSchemaVersion,
39    /// How the usage was found.
40    pub confidence: UsageConfidence,
41    /// One entry per imported name, sorted by `name` in byte order. With a
42    /// specifier filter, only the selected names, each present even when no
43    /// file imports it.
44    pub specifiers: Vec<SpecifierUsage>,
45    /// Uses that hide which names a file reads, counted per file-level form.
46    /// Present with a specifier filter too, because a `require` or a dynamic
47    /// import can hide any name.
48    pub unresolved: FileLevelUnresolved,
49    /// One page of usage sites. Absent unless sites were requested.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub sites: Option<UsageSitePage>,
52    /// The files that import the users of the dependency. Absent unless a
53    /// closure depth was requested.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub closure: Option<ConsumerClosure>,
56}
57
58/// The usage of one imported name.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61pub struct SpecifierUsage {
62    /// The imported name: `default` for a default import, the first member for
63    /// a namespace member, and `*` for a bare namespace use.
64    pub name: String,
65    /// Files with a static import binding of the name, a namespace member use
66    /// of it, or a re-export of it from the package.
67    pub file_count: usize,
68    /// The files in `file_count` where every binding of the name is type-only:
69    /// an `import type`, or a value import that the file reads only in type
70    /// positions.
71    pub type_only_file_count: usize,
72    /// Direct calls of the name, not through a project wrapper. The
73    /// initializer of a call wrapper (`useSelector.withTypes()`) is a direct
74    /// call and counts here.
75    pub call_site_count: usize,
76    /// Uses of the name that the trace cannot resolve to a call.
77    pub unresolved: SpecifierUnresolved,
78    /// Project wrappers of the name, sorted by `file`, then `export`.
79    pub wrappers: Vec<UsageWrapper>,
80}
81
82/// Uses of one imported name that the trace cannot resolve to a call. The key
83/// set is closed in schema version 1.
84#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
85#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
86pub struct SpecifierUnresolved {
87    /// The name is the whole initializer of a declarator that is not a
88    /// wrapper: `const s = useSelector`.
89    pub value_alias: usize,
90    /// Any other value use that is not a call: an argument, an array
91    /// element, a property value or an optional call.
92    pub non_call_reference: usize,
93    /// The name is a JSX element: `<Provider>`.
94    pub jsx_element: usize,
95    /// The file re-exports the name from the package, or re-exports a
96    /// project wrapper of the name. The trace does not follow the consumers
97    /// of the re-export.
98    pub re_export: usize,
99    /// A wrapper consumer that exports the wrapper again. The trace does not
100    /// follow the second hop.
101    pub nested_wrapper: usize,
102    /// Files with a runtime binding of the name and no usage site, for
103    /// example a use in a Vue, Svelte or Astro template.
104    pub binding_without_site: usize,
105}
106
107/// Uses that hide which names a file reads. The key set is closed in schema
108/// version 1.
109#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111pub struct FileLevelUnresolved {
112    /// `import("package")` expressions.
113    pub dynamic_import: usize,
114    /// `require("package")` calls.
115    pub require: usize,
116    /// `import "package"` statements.
117    pub side_effect_import: usize,
118    /// `export * from "package"` statements.
119    pub star_re_export: usize,
120    /// Files that import the package through a specifier that does not name
121    /// the package, for example a path alias.
122    pub unattributed_file: usize,
123}
124
125/// The shape of a project wrapper.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
127#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
128#[serde(rename_all = "snake_case")]
129pub enum WrapperShape {
130    /// The wrapper is the result of a call of the name:
131    /// `useSelector.withTypes<RootState>()`.
132    Call,
133    /// The wrapper is the name itself: `export const useAppDispatch = useDispatch`.
134    Alias,
135}
136
137/// A top-level exported declarator that wraps an imported name. The trace
138/// follows one hop from the wrapper to its consumers.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
140#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
141pub struct UsageWrapper {
142    /// The file that declares the wrapper, root-relative.
143    pub file: String,
144    /// The exported name of the wrapper.
145    pub export: String,
146    /// The shape of the wrapper.
147    pub shape: WrapperShape,
148    /// 1-based line of the wrapper initializer.
149    pub line: u32,
150    /// Distinct files with a call of the wrapper, the file of the wrapper
151    /// included.
152    pub consumer_file_count: usize,
153    /// Calls of the wrapper itself. A call of a member of the value that a
154    /// call wrapper returns (`store.dispatch()`) is a `non_call_reference`.
155    pub call_site_count: usize,
156}
157
158/// One page of usage sites.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
160#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
161pub struct UsageSitePage {
162    /// The sites on this page, sorted by `file`, `line`, `col`, kind,
163    /// `specifier`, then `via`.
164    pub items: Vec<UsageSite>,
165    /// The number of sites on all pages.
166    pub total: usize,
167    /// The largest number of items on a page.
168    pub limit: u16,
169    /// An opaque token for the next page. Absent on the last page.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub next_cursor: Option<String>,
172}
173
174/// One use of the dependency in the code.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
176#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
177pub struct UsageSite {
178    /// The file, root-relative.
179    pub file: String,
180    /// 1-based line.
181    pub line: u32,
182    /// 0-based byte column.
183    pub col: u32,
184    /// The imported name. Absent on file-level kinds.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub specifier: Option<String>,
187    /// The local binding that the code uses. Absent on file-level kinds.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub local_name: Option<String>,
190    /// The static member after the imported name, for example `withTypes`.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub member: Option<String>,
193    /// How the code uses the dependency at this site. The known values are
194    /// `call`, `wrapper_definition`, `value_alias`, `non_call_reference`,
195    /// `jsx_element`, `re_export`, `nested_wrapper`, `dynamic_import`,
196    /// `require`, `side_effect_import` and `star_re_export`. The set is open:
197    /// read an unknown kind as an unresolved site.
198    #[cfg_attr(feature = "schema", schemars(with = "String"))]
199    pub kind: UsageSiteKind,
200    /// The wrapper that the site goes through, as `FILE:EXPORT`.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub via: Option<String>,
203}
204
205/// How the code uses the dependency at a site.
206///
207/// The set is open: read an unknown kind as an unresolved site. The JSON
208/// schema types the field as a plain string for this reason. The order of the
209/// variants is the sort order of sites at the same position.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
211#[serde(rename_all = "snake_case")]
212pub enum UsageSiteKind {
213    /// A call of the name, directly or through a wrapper.
214    Call,
215    /// The initializer of a project wrapper.
216    WrapperDefinition,
217    /// The whole initializer of a declarator that is not a wrapper.
218    ValueAlias,
219    /// A value use that is not a call.
220    NonCallReference,
221    /// A JSX element.
222    JsxElement,
223    /// A named re-export from the package, or a re-export of a project
224    /// wrapper.
225    ReExport,
226    /// A wrapper consumer that exports the wrapper again.
227    NestedWrapper,
228    /// An `import("package")` expression.
229    DynamicImport,
230    /// A `require("package")` call.
231    Require,
232    /// An `import "package"` statement.
233    SideEffectImport,
234    /// An `export * from "package"` statement.
235    StarReExport,
236}
237
238impl UsageSiteKind {
239    /// The `snake_case` wire name of the kind.
240    #[must_use]
241    pub const fn as_str(self) -> &'static str {
242        match self {
243            Self::Call => "call",
244            Self::WrapperDefinition => "wrapper_definition",
245            Self::ValueAlias => "value_alias",
246            Self::NonCallReference => "non_call_reference",
247            Self::JsxElement => "jsx_element",
248            Self::ReExport => "re_export",
249            Self::NestedWrapper => "nested_wrapper",
250            Self::DynamicImport => "dynamic_import",
251            Self::Require => "require",
252            Self::SideEffectImport => "side_effect_import",
253            Self::StarReExport => "star_re_export",
254        }
255    }
256}
257
258/// The files that import the users of the dependency, found by a reverse walk
259/// of the import graph.
260#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
261#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
262pub struct ConsumerClosure {
263    /// The largest depth of the walk.
264    pub depth: u32,
265    /// The number of files in `files`.
266    pub file_count: usize,
267    /// The files, sorted by `depth`, then `file`. The seed files are not
268    /// listed.
269    pub files: Vec<ClosureFile>,
270    /// Whether a file at the largest depth has an importer that the walk did
271    /// not visit.
272    pub truncated: bool,
273}
274
275/// One file of a [`ConsumerClosure`].
276#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
277#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
278pub struct ClosureFile {
279    /// The file, root-relative.
280    pub file: String,
281    /// The number of import edges from the nearest seed file.
282    pub depth: u32,
283}
284
285/// The default number of sites on a page.
286pub const DEFAULT_USAGE_SITE_LIMIT: u16 = 50;
287/// The largest number of sites on a page.
288pub const MAX_USAGE_SITE_LIMIT: u16 = 500;
289/// The largest closure depth.
290pub const MAX_USAGE_CLOSURE_DEPTH: u32 = 10;
291
292/// A request for one page of usage sites.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct SitePageRequest {
295    /// The largest number of sites on the page, 1 to 500.
296    pub limit: u16,
297    /// The `next_cursor` of the previous page.
298    pub cursor: Option<String>,
299}
300
301/// What a dependency usage trace reports.
302#[derive(Debug, Clone, Default, PartialEq, Eq)]
303pub struct DependencyUsageQuery {
304    /// Imported names to report. Empty reports all names.
305    pub specifiers: Vec<String>,
306    /// The page of sites to report, or `None` for counts only.
307    pub sites: Option<SitePageRequest>,
308    /// The depth of the consumer closure, or `None` for no closure.
309    pub closure_depth: Option<u32>,
310}
311
312/// An invalid [`DependencyUsageQuery`].
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub enum UsageQueryError {
315    /// The site limit is not in 1..=500.
316    LimitOutOfRange(u16),
317    /// The closure depth is not in 1..=10.
318    DepthOutOfRange(u32),
319}
320
321impl std::fmt::Display for UsageQueryError {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        match self {
324            Self::LimitOutOfRange(limit) => write!(
325                f,
326                "the site limit must be between 1 and {MAX_USAGE_SITE_LIMIT}, got {limit}"
327            ),
328            Self::DepthOutOfRange(depth) => write!(
329                f,
330                "the closure depth must be between 1 and {MAX_USAGE_CLOSURE_DEPTH}, got {depth}"
331            ),
332        }
333    }
334}
335
336impl std::error::Error for UsageQueryError {}
337
338impl DependencyUsageQuery {
339    /// Build a validated query.
340    ///
341    /// # Errors
342    ///
343    /// Returns an error when the site limit is not in 1..=500 or the closure
344    /// depth is not in 1..=10.
345    pub fn new(
346        specifiers: Vec<String>,
347        sites: Option<SitePageRequest>,
348        closure_depth: Option<u32>,
349    ) -> Result<Self, UsageQueryError> {
350        if let Some(page) = &sites
351            && !(1..=MAX_USAGE_SITE_LIMIT).contains(&page.limit)
352        {
353            return Err(UsageQueryError::LimitOutOfRange(page.limit));
354        }
355        if let Some(depth) = closure_depth
356            && !(1..=MAX_USAGE_CLOSURE_DEPTH).contains(&depth)
357        {
358            return Err(UsageQueryError::DepthOutOfRange(depth));
359        }
360        Ok(Self {
361            specifiers,
362            sites,
363            closure_depth,
364        })
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn query_rejects_out_of_range_limit_and_depth() {
374        let page = |limit| {
375            Some(SitePageRequest {
376                limit,
377                cursor: None,
378            })
379        };
380        assert_eq!(
381            DependencyUsageQuery::new(Vec::new(), page(0), None),
382            Err(UsageQueryError::LimitOutOfRange(0))
383        );
384        assert_eq!(
385            DependencyUsageQuery::new(Vec::new(), page(501), None),
386            Err(UsageQueryError::LimitOutOfRange(501))
387        );
388        assert_eq!(
389            DependencyUsageQuery::new(Vec::new(), None, Some(0)),
390            Err(UsageQueryError::DepthOutOfRange(0))
391        );
392        assert_eq!(
393            DependencyUsageQuery::new(Vec::new(), None, Some(11)),
394            Err(UsageQueryError::DepthOutOfRange(11))
395        );
396        assert!(DependencyUsageQuery::new(Vec::new(), page(500), Some(10)).is_ok());
397        assert!(DependencyUsageQuery::new(Vec::new(), page(1), Some(1)).is_ok());
398    }
399
400    #[test]
401    fn site_kind_wire_names_match_serde() {
402        for kind in [
403            UsageSiteKind::Call,
404            UsageSiteKind::WrapperDefinition,
405            UsageSiteKind::ValueAlias,
406            UsageSiteKind::NonCallReference,
407            UsageSiteKind::JsxElement,
408            UsageSiteKind::ReExport,
409            UsageSiteKind::NestedWrapper,
410            UsageSiteKind::DynamicImport,
411            UsageSiteKind::Require,
412            UsageSiteKind::SideEffectImport,
413            UsageSiteKind::StarReExport,
414        ] {
415            assert_eq!(
416                serde_json::to_value(kind).expect("a site kind serializes"),
417                serde_json::Value::String(kind.as_str().to_owned())
418            );
419        }
420    }
421}