Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/package/typst-gather/src/lib.rs
6462 views
1
//! typst-gather: Gather Typst packages locally for offline/hermetic builds.
2
3
use std::collections::HashMap;
4
use std::collections::HashSet;
5
use std::env;
6
use std::path::{Path, PathBuf};
7
8
use ecow::EcoString;
9
use globset::{Glob, GlobSetBuilder};
10
use serde::Deserialize;
11
use typst_kit::download::{Downloader, ProgressSink};
12
use typst_kit::package::PackageStorage;
13
use typst_syntax::ast;
14
use typst_syntax::package::{PackageManifest, PackageSpec, PackageVersion};
15
use typst_syntax::SyntaxNode;
16
use walkdir::WalkDir;
17
18
/// Statistics about gathering operations.
19
#[derive(Debug, Default, PartialEq, Eq)]
20
pub struct Stats {
21
pub downloaded: usize,
22
pub copied: usize,
23
pub skipped: usize,
24
pub failed: usize,
25
}
26
27
/// Result of a gather operation.
28
#[derive(Debug, Default)]
29
pub struct GatherResult {
30
pub stats: Stats,
31
/// @local imports discovered during scanning that are not configured in [local] section.
32
/// Each entry is (package_name, source_file_path).
33
pub unconfigured_local: Vec<(String, String)>,
34
}
35
36
/// TOML configuration format.
37
///
38
/// ```toml
39
/// destination = "/path/to/packages"
40
/// discover = ["/path/to/templates", "/path/to/other.typ"]
41
///
42
/// [preview]
43
/// cetz = "0.4.1"
44
/// fletcher = "0.5.3"
45
///
46
/// [local]
47
/// my-pkg = "/path/to/pkg"
48
/// ```
49
/// Helper enum for deserializing string or array of strings
50
#[derive(Debug, Deserialize)]
51
#[serde(untagged)]
52
enum StringOrVec {
53
Single(String),
54
Multiple(Vec<String>),
55
}
56
57
impl Default for StringOrVec {
58
fn default() -> Self {
59
StringOrVec::Multiple(Vec::new())
60
}
61
}
62
63
impl From<StringOrVec> for Vec<PathBuf> {
64
fn from(value: StringOrVec) -> Self {
65
match value {
66
StringOrVec::Single(s) => vec![PathBuf::from(s)],
67
StringOrVec::Multiple(v) => v.into_iter().map(PathBuf::from).collect(),
68
}
69
}
70
}
71
72
/// Raw config for deserialization
73
#[derive(Debug, Deserialize, Default)]
74
struct RawConfig {
75
/// Root directory for resolving relative paths (discover, destination)
76
rootdir: Option<PathBuf>,
77
destination: Option<PathBuf>,
78
#[serde(default)]
79
discover: Option<StringOrVec>,
80
#[serde(default)]
81
preview: HashMap<String, String>,
82
#[serde(default)]
83
local: HashMap<String, String>,
84
}
85
86
#[derive(Debug, Default)]
87
pub struct Config {
88
/// Root directory for resolving relative paths (discover, destination).
89
/// If set, discover and destination paths are resolved relative to this.
90
pub rootdir: Option<PathBuf>,
91
/// Destination directory for gathered packages
92
pub destination: Option<PathBuf>,
93
/// Paths to scan for imports. Can be directories (scans .typ files) or individual .typ files.
94
/// Accepts either a single path or an array of paths.
95
pub discover: Vec<PathBuf>,
96
pub preview: HashMap<String, String>,
97
pub local: HashMap<String, String>,
98
}
99
100
impl From<RawConfig> for Config {
101
fn from(raw: RawConfig) -> Self {
102
Config {
103
rootdir: raw.rootdir,
104
destination: raw.destination,
105
discover: raw.discover.map(Into::into).unwrap_or_default(),
106
preview: raw.preview,
107
local: raw.local,
108
}
109
}
110
}
111
112
/// A resolved package entry ready for gathering.
113
#[derive(Debug, Clone, PartialEq, Eq)]
114
pub enum PackageEntry {
115
Preview { name: String, version: String },
116
Local { name: String, dir: PathBuf },
117
}
118
119
impl Config {
120
/// Parse a TOML configuration string.
121
pub fn parse(content: &str) -> Result<Self, toml::de::Error> {
122
let raw: RawConfig = toml::from_str(content)?;
123
Ok(raw.into())
124
}
125
126
/// Convert config into a list of package entries.
127
pub fn into_entries(self) -> Vec<PackageEntry> {
128
let mut entries = Vec::new();
129
130
for (name, version) in self.preview {
131
entries.push(PackageEntry::Preview { name, version });
132
}
133
134
for (name, dir) in self.local {
135
entries.push(PackageEntry::Local {
136
name,
137
dir: PathBuf::from(dir),
138
});
139
}
140
141
entries
142
}
143
}
144
145
/// Context for gathering operations, holding shared state.
146
struct GatherContext<'a> {
147
storage: PackageStorage,
148
dest: &'a Path,
149
configured_local: &'a HashSet<String>,
150
processed: HashSet<String>,
151
stats: Stats,
152
/// @local imports discovered during scanning (name -> source_file)
153
discovered_local: HashMap<String, String>,
154
}
155
156
impl<'a> GatherContext<'a> {
157
fn new(dest: &'a Path, configured_local: &'a HashSet<String>) -> Self {
158
Self {
159
storage: PackageStorage::new(
160
Some(dest.to_path_buf()),
161
None,
162
Downloader::new("typst-gather/0.1.0"),
163
),
164
dest,
165
configured_local,
166
processed: HashSet::new(),
167
stats: Stats::default(),
168
discovered_local: HashMap::new(),
169
}
170
}
171
}
172
173
/// Gather packages to the destination directory.
174
pub fn gather_packages(
175
dest: &Path,
176
entries: Vec<PackageEntry>,
177
discover_paths: &[PathBuf],
178
configured_local: &HashSet<String>,
179
) -> GatherResult {
180
let mut ctx = GatherContext::new(dest, configured_local);
181
182
// First, process discover paths
183
for path in discover_paths {
184
discover_imports(&mut ctx, path);
185
}
186
187
// Then process explicit entries
188
for entry in entries {
189
match entry {
190
PackageEntry::Preview { name, version } => {
191
cache_preview(&mut ctx, &name, &version);
192
}
193
PackageEntry::Local { name, dir } => {
194
gather_local(&mut ctx, &name, &dir);
195
}
196
}
197
}
198
199
// Find @local imports that aren't configured
200
let unconfigured_local: Vec<(String, String)> = ctx.discovered_local
201
.into_iter()
202
.filter(|(name, _)| !ctx.configured_local.contains(name))
203
.collect();
204
205
GatherResult {
206
stats: ctx.stats,
207
unconfigured_local,
208
}
209
}
210
211
/// Scan a path for imports. If it's a directory, scans .typ files in it (non-recursive).
212
/// If it's a file, scans that file directly.
213
fn discover_imports(ctx: &mut GatherContext, path: &Path) {
214
if path.is_file() {
215
// Single file
216
if path.extension().is_some_and(|e| e == "typ") {
217
println!("Discovering imports in {}...", display_path(path));
218
scan_file_for_imports(ctx, path);
219
}
220
} else if path.is_dir() {
221
// Directory - scan .typ files (non-recursive)
222
println!("Discovering imports in {}...", display_path(path));
223
224
let entries = match std::fs::read_dir(path) {
225
Ok(e) => e,
226
Err(e) => {
227
eprintln!(" Failed to read directory: {e}");
228
ctx.stats.failed += 1;
229
return;
230
}
231
};
232
233
for entry in entries.flatten() {
234
let file_path = entry.path();
235
if file_path.is_file() && file_path.extension().is_some_and(|e| e == "typ") {
236
scan_file_for_imports(ctx, &file_path);
237
}
238
}
239
} else {
240
eprintln!("Warning: discover path does not exist: {}", display_path(path));
241
}
242
}
243
244
/// Scan a single .typ file for @preview and @local imports.
245
/// @preview imports are cached, @local imports are tracked for later warning.
246
fn scan_file_for_imports(ctx: &mut GatherContext, path: &Path) {
247
if let Ok(content) = std::fs::read_to_string(path) {
248
let mut imports = Vec::new();
249
collect_imports(&typst_syntax::parse(&content), &mut imports);
250
251
let source_file = path.file_name()
252
.map(|s| s.to_string_lossy().to_string())
253
.unwrap_or_else(|| path.display().to_string());
254
255
for spec in imports {
256
if spec.namespace == "preview" {
257
cache_preview_with_deps(ctx, &spec);
258
} else if spec.namespace == "local" {
259
// Track @local imports (only first occurrence per package name)
260
ctx.discovered_local.entry(spec.name.to_string())
261
.or_insert(source_file.clone());
262
}
263
}
264
}
265
}
266
267
fn cache_preview(ctx: &mut GatherContext, name: &str, version_str: &str) {
268
let Ok(version): Result<PackageVersion, _> = version_str.parse() else {
269
eprintln!("Invalid version '{version_str}' for @preview/{name}");
270
ctx.stats.failed += 1;
271
return;
272
};
273
274
let spec = PackageSpec {
275
namespace: EcoString::from("preview"),
276
name: EcoString::from(name),
277
version,
278
};
279
280
cache_preview_with_deps(ctx, &spec);
281
}
282
283
/// Default exclude patterns for local packages (common non-package files).
284
const DEFAULT_EXCLUDES: &[&str] = &[
285
".git",
286
".git/**",
287
".github",
288
".github/**",
289
".gitignore",
290
".gitattributes",
291
".vscode",
292
".vscode/**",
293
".idea",
294
".idea/**",
295
"*.bak",
296
"*.swp",
297
"*~",
298
];
299
300
fn gather_local(ctx: &mut GatherContext, name: &str, src_dir: &Path) {
301
// Read typst.toml to get version (and validate name)
302
let manifest_path = src_dir.join("typst.toml");
303
let manifest: PackageManifest = match std::fs::read_to_string(&manifest_path)
304
.map_err(|e| e.to_string())
305
.and_then(|s| toml::from_str(&s).map_err(|e| e.to_string()))
306
{
307
Ok(m) => m,
308
Err(e) => {
309
eprintln!("Error reading typst.toml for @local/{name}: {e}");
310
ctx.stats.failed += 1;
311
return;
312
}
313
};
314
315
// Validate name matches
316
if manifest.package.name.as_str() != name {
317
eprintln!(
318
"Name mismatch for @local/{name}: typst.toml has '{}'",
319
manifest.package.name
320
);
321
ctx.stats.failed += 1;
322
return;
323
}
324
325
let version = manifest.package.version;
326
let dest_dir = ctx.dest.join(format!("local/{name}/{version}"));
327
328
println!("Copying @local/{name}:{version}...");
329
330
// Clean slate: remove destination if exists
331
if dest_dir.exists() {
332
if let Err(e) = std::fs::remove_dir_all(&dest_dir) {
333
eprintln!(" Failed to remove existing dir: {e}");
334
ctx.stats.failed += 1;
335
return;
336
}
337
}
338
339
// Build exclude pattern matcher from defaults + manifest excludes
340
let mut builder = GlobSetBuilder::new();
341
for pattern in DEFAULT_EXCLUDES {
342
if let Ok(glob) = Glob::new(pattern) {
343
builder.add(glob);
344
}
345
}
346
// Add manifest excludes if present
347
for pattern in &manifest.package.exclude {
348
if let Ok(glob) = Glob::new(pattern.as_str()) {
349
builder.add(glob);
350
}
351
}
352
let excludes = builder.build().unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap());
353
354
// Copy files, respecting exclude patterns
355
if let Err(e) = copy_filtered(src_dir, &dest_dir, &excludes) {
356
eprintln!(" Failed to copy: {e}");
357
ctx.stats.failed += 1;
358
return;
359
}
360
361
println!(" -> {}", display_path(&dest_dir));
362
ctx.stats.copied += 1;
363
364
// Mark as processed
365
let spec = PackageSpec {
366
namespace: EcoString::from("local"),
367
name: EcoString::from(name),
368
version,
369
};
370
ctx.processed.insert(spec.to_string());
371
372
// Scan for @preview dependencies
373
scan_deps(ctx, &dest_dir);
374
}
375
376
/// Copy directory contents, excluding files that match the exclude patterns.
377
fn copy_filtered(
378
src: &Path,
379
dest: &Path,
380
excludes: &globset::GlobSet,
381
) -> std::io::Result<()> {
382
std::fs::create_dir_all(dest)?;
383
384
for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) {
385
let path = entry.path();
386
let relative = path.strip_prefix(src).unwrap_or(path);
387
388
// Check if this path matches any exclude pattern
389
if excludes.is_match(relative) {
390
continue;
391
}
392
393
let dest_path = dest.join(relative);
394
395
if path.is_dir() {
396
std::fs::create_dir_all(&dest_path)?;
397
} else if path.is_file() {
398
if let Some(parent) = dest_path.parent() {
399
std::fs::create_dir_all(parent)?;
400
}
401
std::fs::copy(path, &dest_path)?;
402
}
403
}
404
405
Ok(())
406
}
407
408
fn cache_preview_with_deps(ctx: &mut GatherContext, spec: &PackageSpec) {
409
// Skip @preview packages that are configured as @local (use local version instead)
410
if ctx.configured_local.contains(spec.name.as_str()) {
411
return;
412
}
413
414
let key = spec.to_string();
415
if !ctx.processed.insert(key) {
416
return;
417
}
418
419
let subdir = format!("{}/{}/{}", spec.namespace, spec.name, spec.version);
420
let cached_path = ctx.storage.package_cache_path().map(|p| p.join(&subdir));
421
422
if cached_path.as_ref().is_some_and(|p| p.exists()) {
423
println!("Skipping {spec} (cached)");
424
ctx.stats.skipped += 1;
425
scan_deps(ctx, cached_path.as_ref().unwrap());
426
return;
427
}
428
429
println!("Downloading {spec}...");
430
match ctx.storage.prepare_package(spec, &mut ProgressSink) {
431
Ok(path) => {
432
println!(" -> {}", display_path(&path));
433
ctx.stats.downloaded += 1;
434
scan_deps(ctx, &path);
435
}
436
Err(e) => {
437
eprintln!(" Failed: {e:?}");
438
ctx.stats.failed += 1;
439
}
440
}
441
}
442
443
fn scan_deps(ctx: &mut GatherContext, dir: &Path) {
444
for spec in find_imports(dir) {
445
if spec.namespace == "preview" {
446
cache_preview_with_deps(ctx, &spec);
447
}
448
}
449
}
450
451
/// Display a path relative to the current working directory.
452
fn display_path(path: &Path) -> String {
453
if let Ok(cwd) = env::current_dir() {
454
if let Ok(relative) = path.strip_prefix(&cwd) {
455
return relative.display().to_string();
456
}
457
}
458
path.display().to_string()
459
}
460
461
/// Find all package imports in `.typ` files under a directory.
462
pub fn find_imports(dir: &Path) -> Vec<PackageSpec> {
463
let mut imports = Vec::new();
464
for entry in WalkDir::new(dir).into_iter().flatten() {
465
if entry.path().extension().is_some_and(|e| e == "typ") {
466
if let Ok(content) = std::fs::read_to_string(entry.path()) {
467
collect_imports(&typst_syntax::parse(&content), &mut imports);
468
}
469
}
470
}
471
imports
472
}
473
474
/// Extract package imports from a Typst syntax tree.
475
pub fn collect_imports(node: &SyntaxNode, imports: &mut Vec<PackageSpec>) {
476
if let Some(import) = node.cast::<ast::ModuleImport>() {
477
if let Some(spec) = try_extract_spec(import.source()) {
478
imports.push(spec);
479
}
480
}
481
if let Some(include) = node.cast::<ast::ModuleInclude>() {
482
if let Some(spec) = try_extract_spec(include.source()) {
483
imports.push(spec);
484
}
485
}
486
for child in node.children() {
487
collect_imports(child, imports);
488
}
489
}
490
491
/// Try to extract a PackageSpec from an expression (if it's an `@namespace/name:version` string).
492
pub fn try_extract_spec(expr: ast::Expr) -> Option<PackageSpec> {
493
if let ast::Expr::Str(s) = expr {
494
let val = s.get();
495
if val.starts_with('@') {
496
return val.parse().ok();
497
}
498
}
499
None
500
}
501
502
#[cfg(test)]
503
mod tests {
504
use super::*;
505
506
mod config_parsing {
507
use super::*;
508
509
#[test]
510
fn empty_config() {
511
let config = Config::parse("").unwrap();
512
assert!(config.destination.is_none());
513
assert!(config.discover.is_empty());
514
assert!(config.preview.is_empty());
515
assert!(config.local.is_empty());
516
}
517
518
#[test]
519
fn destination_only() {
520
let toml = r#"destination = "/path/to/cache""#;
521
let config = Config::parse(toml).unwrap();
522
assert_eq!(config.destination, Some(PathBuf::from("/path/to/cache")));
523
assert!(config.discover.is_empty());
524
assert!(config.preview.is_empty());
525
assert!(config.local.is_empty());
526
}
527
528
#[test]
529
fn with_discover_string() {
530
let toml = r#"
531
destination = "/cache"
532
discover = "/path/to/templates"
533
"#;
534
let config = Config::parse(toml).unwrap();
535
assert_eq!(config.destination, Some(PathBuf::from("/cache")));
536
assert_eq!(config.discover, vec![PathBuf::from("/path/to/templates")]);
537
}
538
539
#[test]
540
fn with_discover_array() {
541
let toml = r#"
542
destination = "/cache"
543
discover = ["/path/to/templates", "template.typ", "other.typ"]
544
"#;
545
let config = Config::parse(toml).unwrap();
546
assert_eq!(config.destination, Some(PathBuf::from("/cache")));
547
assert_eq!(
548
config.discover,
549
vec![
550
PathBuf::from("/path/to/templates"),
551
PathBuf::from("template.typ"),
552
PathBuf::from("other.typ"),
553
]
554
);
555
}
556
557
#[test]
558
fn preview_only() {
559
let toml = r#"
560
destination = "/cache"
561
562
[preview]
563
cetz = "0.4.1"
564
fletcher = "0.5.3"
565
"#;
566
let config = Config::parse(toml).unwrap();
567
assert_eq!(config.destination, Some(PathBuf::from("/cache")));
568
assert_eq!(config.preview.len(), 2);
569
assert_eq!(config.preview.get("cetz"), Some(&"0.4.1".to_string()));
570
assert_eq!(config.preview.get("fletcher"), Some(&"0.5.3".to_string()));
571
assert!(config.local.is_empty());
572
}
573
574
#[test]
575
fn local_only() {
576
let toml = r#"
577
destination = "/cache"
578
579
[local]
580
my-pkg = "/path/to/pkg"
581
other = "../relative/path"
582
"#;
583
let config = Config::parse(toml).unwrap();
584
assert!(config.preview.is_empty());
585
assert_eq!(config.local.len(), 2);
586
assert_eq!(config.local.get("my-pkg"), Some(&"/path/to/pkg".to_string()));
587
assert_eq!(config.local.get("other"), Some(&"../relative/path".to_string()));
588
}
589
590
#[test]
591
fn mixed_config() {
592
let toml = r#"
593
destination = "/cache"
594
595
[preview]
596
cetz = "0.4.1"
597
598
[local]
599
my-pkg = "/path/to/pkg"
600
"#;
601
let config = Config::parse(toml).unwrap();
602
assert_eq!(config.destination, Some(PathBuf::from("/cache")));
603
assert_eq!(config.preview.len(), 1);
604
assert_eq!(config.local.len(), 1);
605
}
606
607
#[test]
608
fn into_entries() {
609
let toml = r#"
610
destination = "/cache"
611
612
[preview]
613
cetz = "0.4.1"
614
615
[local]
616
my-pkg = "/path/to/pkg"
617
"#;
618
let config = Config::parse(toml).unwrap();
619
let entries = config.into_entries();
620
assert_eq!(entries.len(), 2);
621
622
let has_preview = entries.iter().any(|e| {
623
matches!(e, PackageEntry::Preview { name, version }
624
if name == "cetz" && version == "0.4.1")
625
});
626
let has_local = entries.iter().any(|e| {
627
matches!(e, PackageEntry::Local { name, dir }
628
if name == "my-pkg" && dir == Path::new("/path/to/pkg"))
629
});
630
assert!(has_preview);
631
assert!(has_local);
632
}
633
634
#[test]
635
fn invalid_toml() {
636
let result = Config::parse("not valid toml [[[");
637
assert!(result.is_err());
638
}
639
640
#[test]
641
fn extra_fields_ignored() {
642
let toml = r#"
643
destination = "/cache"
644
645
[preview]
646
cetz = "0.4.1"
647
648
[unknown_section]
649
foo = "bar"
650
"#;
651
// Should not error on unknown sections
652
let config = Config::parse(toml).unwrap();
653
assert_eq!(config.preview.len(), 1);
654
}
655
}
656
657
mod import_parsing {
658
use super::*;
659
660
fn parse_imports(code: &str) -> Vec<PackageSpec> {
661
let mut imports = Vec::new();
662
collect_imports(&typst_syntax::parse(code), &mut imports);
663
imports
664
}
665
666
#[test]
667
fn simple_import() {
668
let imports = parse_imports(r#"#import "@preview/cetz:0.4.1""#);
669
assert_eq!(imports.len(), 1);
670
assert_eq!(imports[0].namespace, "preview");
671
assert_eq!(imports[0].name, "cetz");
672
assert_eq!(imports[0].version.to_string(), "0.4.1");
673
}
674
675
#[test]
676
fn import_with_items() {
677
let imports = parse_imports(r#"#import "@preview/cetz:0.4.1": canvas, draw"#);
678
assert_eq!(imports.len(), 1);
679
assert_eq!(imports[0].name, "cetz");
680
}
681
682
#[test]
683
fn multiple_imports() {
684
let code = r#"
685
#import "@preview/cetz:0.4.1"
686
#import "@preview/fletcher:0.5.3"
687
"#;
688
let imports = parse_imports(code);
689
assert_eq!(imports.len(), 2);
690
}
691
692
#[test]
693
fn include_statement() {
694
let imports = parse_imports(r#"#include "@preview/template:1.0.0""#);
695
assert_eq!(imports.len(), 1);
696
assert_eq!(imports[0].name, "template");
697
}
698
699
#[test]
700
fn local_import_ignored_in_extract() {
701
// Local imports are valid but won't be recursively fetched
702
let imports = parse_imports(r#"#import "@local/my-pkg:1.0.0""#);
703
assert_eq!(imports.len(), 1);
704
assert_eq!(imports[0].namespace, "local");
705
}
706
707
#[test]
708
fn relative_import_ignored() {
709
let imports = parse_imports(r#"#import "utils.typ""#);
710
assert_eq!(imports.len(), 0);
711
}
712
713
#[test]
714
fn no_imports() {
715
let imports = parse_imports(r#"= Hello World"#);
716
assert_eq!(imports.len(), 0);
717
}
718
719
#[test]
720
fn nested_in_function() {
721
let code = r#"
722
#let setup() = {
723
import "@preview/cetz:0.4.1"
724
}
725
"#;
726
let imports = parse_imports(code);
727
assert_eq!(imports.len(), 1);
728
}
729
730
#[test]
731
fn invalid_package_spec_ignored() {
732
// Missing version
733
let imports = parse_imports(r#"#import "@preview/cetz""#);
734
assert_eq!(imports.len(), 0);
735
}
736
737
#[test]
738
fn complex_document() {
739
let code = r#"
740
#import "@preview/cetz:0.4.1": canvas
741
#import "@preview/fletcher:0.5.3": diagram, node, edge
742
#import "local-file.typ": helper
743
744
= My Document
745
746
#include "@preview/template:1.0.0"
747
748
Some content here.
749
750
#let f() = {
751
import "@preview/codly:1.2.0"
752
}
753
"#;
754
let imports = parse_imports(code);
755
assert_eq!(imports.len(), 4);
756
757
let names: Vec<_> = imports.iter().map(|s| s.name.as_str()).collect();
758
assert!(names.contains(&"cetz"));
759
assert!(names.contains(&"fletcher"));
760
assert!(names.contains(&"template"));
761
assert!(names.contains(&"codly"));
762
}
763
}
764
765
mod stats {
766
use super::*;
767
768
#[test]
769
fn default_stats() {
770
let stats = Stats::default();
771
assert_eq!(stats.downloaded, 0);
772
assert_eq!(stats.copied, 0);
773
assert_eq!(stats.skipped, 0);
774
assert_eq!(stats.failed, 0);
775
}
776
}
777
778
mod local_override {
779
use super::*;
780
781
/// When a package is configured in [local], @preview imports of the same
782
/// package name should be skipped. This handles the case where a local
783
/// package contains template examples that import from @preview.
784
#[test]
785
fn configured_local_contains_check() {
786
let mut configured_local = HashSet::new();
787
configured_local.insert("my-pkg".to_string());
788
configured_local.insert("other-pkg".to_string());
789
790
// These should be skipped (configured as local)
791
assert!(configured_local.contains("my-pkg"));
792
assert!(configured_local.contains("other-pkg"));
793
794
// These should NOT be skipped (not configured)
795
assert!(!configured_local.contains("cetz"));
796
assert!(!configured_local.contains("fletcher"));
797
}
798
}
799
800
mod copy_filtering {
801
use super::*;
802
803
#[test]
804
fn default_excludes_match_git() {
805
let mut builder = GlobSetBuilder::new();
806
for pattern in DEFAULT_EXCLUDES {
807
builder.add(Glob::new(pattern).unwrap());
808
}
809
let excludes = builder.build().unwrap();
810
811
// Should match .git and contents
812
assert!(excludes.is_match(".git"));
813
assert!(excludes.is_match(".git/config"));
814
assert!(excludes.is_match(".git/objects/pack/foo"));
815
816
// Should match .github
817
assert!(excludes.is_match(".github"));
818
assert!(excludes.is_match(".github/workflows/ci.yml"));
819
820
// Should match editor files
821
assert!(excludes.is_match(".gitignore"));
822
assert!(excludes.is_match("foo.bak"));
823
assert!(excludes.is_match("foo.swp"));
824
assert!(excludes.is_match("foo~"));
825
826
// Should NOT match normal files
827
assert!(!excludes.is_match("lib.typ"));
828
assert!(!excludes.is_match("typst.toml"));
829
assert!(!excludes.is_match("src/main.typ"));
830
assert!(!excludes.is_match("template/main.typ"));
831
}
832
}
833
}
834
835