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}