Repository navigation
Expand file tree
/
Copy pathcache.rs
More file actions
3496 lines (3272 loc) · 141 KB
/
Copy pathcache.rs
File metadata and controls
3496 lines (3272 loc) · 141 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (C) 2026 Bryan A. Jones.
//
// This file is part of the CodeChat Editor. The CodeChat Editor is free
// software: you can redistribute it and/or modify it under the terms of the GNU
// General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// The CodeChat Editor is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
// details.
//
// You should have received a copy of the GNU General Public License along with
// the CodeChat Editor. If not, see
// [http://www.gnu.org/licenses](http://www.gnu.org/licenses).
//! `cache.rs` -- Cache all targets and fragments in a project
//! ==========================================================
//!
//! See the [cache spec](cache-spec.md).
// Imports
// -------
//
// ### Standard library
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
use std::{
collections::{BTreeMap, HashMap, HashSet},
fs::{self, File, Metadata},
io::{self, Read},
mem,
num::NonZeroUsize,
ops::Bound,
path::{Path, PathBuf},
str,
sync::{
Arc, Mutex, PoisonError, Weak,
mpsc::{self, Receiver, SendError, Sender},
},
thread,
time::{Duration, SystemTime},
};
// ### Third-party
use ignore::{IncrementalIgnore, WalkBuilder};
use log::warn;
use lru::LruCache;
use notify_debouncer_full::{
DebounceEventResult, Debouncer, RecommendedCache, new_debouncer_opt,
notify::{
self, Event, EventKind, RecommendedWatcher, RecursiveMode,
event::{ModifyKind, RenameMode},
},
};
use rand::random;
use tokio::sync::mpsc::UnboundedSender;
// ### Local
use super::{SourceToCodeChatForWebError, source_to_codechat_for_web};
// Data structures
// ---------------
/// The set of all project caches, keyed by each project's root directory (the
/// directory containing its `toc.md`). The outer mutex guards the addition of
/// newly-discovered projects; each inner mutex guards a single project's cache,
/// so that work on one project doesn't block work on another. When both are
/// needed, lock the outer mutex first.
pub type CacheMap = Arc<Mutex<ProjectCaches>>;
/// The contents of a `CacheMap`. Insert into and resize it only through
/// `get_or_insert_cache` and `set_cache_map_capacity`, which evict the least
/// recently used cache when full -- but never a cache in which a connection is
/// registered.
pub type ProjectCaches = LruCache<PathBuf, Arc<Mutex<Cache>>>;
/// The number of project caches the `CacheMap` holds per connection; see
/// `cache_map_capacity`.
pub const LRU_ENTRIES_PER_CONNECTION: usize = 10;
/// This defines the cache used to store all targets and fragments in a project.
pub struct Cache {
/// Provide rapid access to a file by its normalized, absolute path; it must
/// be within the project's root directory. This owns all per-file data.
/// Paths order component by component, so the files beneath a directory
/// immediately follow it in this map: finding them, as a deleted or
/// re-walked directory requires, is a range query rather than a search of
/// every file.
pub(super) files: BTreeMap<PathBuf, FileEntry>,
/// Provide rapid access to a `Target`/gather element or `Fragment` by its
/// id.
pub(super) ids: HashMap<String, IdEntry>,
/// The root directory of this project, or a specific file if this is a
/// single-file "project".
pub(super) root: PathBuf,
/// Sends work to this project's consumer thread: the walker sends what it
/// finds through a clone of this sender, `mark_outdated` sends outdated
/// files, and dropping a `ConnectionRegistration` sends the file it showed.
/// `None` if there's no consumer -- a single-file "project", or a test.
consumer_tx: Option<Sender<ConsumerPathState>>,
/// This project's watcher, which the watcher/walker thread stores here once
/// it registers the watcher; `None` until then, if there's no consumer, or
/// if the watcher couldn't start. Dropping it stops the watch, which begins
/// the shutdown sequence described in the spec.
_watcher: Option<Debouncer<RecommendedWatcher, RecommendedCache>>,
/// The connections whose current file lies in this project, keyed by
/// connection id; each entry is owned by a `ConnectionRegistration`.
connections: HashMap<String, ConnectionView>,
// TODO: search engine data storage. Search fields: target ID, contents,
// file name. Perhaps [Tantivy](https://docs.rs/tantivy/latest/tantivy/)?
}
/// This stores the cached data for a given file. For non-page files
/// (non-existent files, images, PDFs, etc.) many of the fields are empty or
/// `None`.
#[derive(Default)]
pub(super) struct FileEntry {
/// The file's stamp, taken when its content was read; the consumer
/// reprocesses the file when it no longer matches. `None` if the file
/// doesn't exist, its metadata can't be determined, or the consumer's
/// last translation of it failed.
pub(super) stamp: Option<FileStamp>,
/// The status of this file.
pub(super) status: FileStatus,
/// All targets on this page, keyed by id.
pub(super) targets: HashMap<String, Target>,
/// All cross references on this page.
pub(super) xrefs: Vec<Xref>,
/// All fragments on this page, keyed by id.
pub(super) fragments: HashMap<String, Fragment>,
/// The ids of all gather elements on this page, in document order. This is
/// an index into `targets` (a gather element is a `Target` whose
/// `gather_ids` is non-empty), maintained so that the gather elements
/// referencing a given fragment can be found without scanning every target.
pub(super) gathers: Vec<String>,
}
/// How long after a file's modification time a stamp of it must be taken for
/// that time to be trusted; see `FileStamp::is_racy`. This spans FAT's 2-second
/// timestamp resolution plus some clock skew on network shares.
const RACY_WINDOW: Duration = Duration::from_secs(3);
/// A snapshot of a file's metadata, taken immediately before its contents are
/// read, which tells the consumer whether the cache still represents the file
/// without reading it again. Git's index stores the same snapshot of each
/// tracked file for the same purpose; see
/// [Use of index and Racy Git problem](https://github.com/git/git/blob/master/Documentation/technical/racy-git.adoc).
pub(super) struct FileStamp {
/// The metadata compared against a later snapshot.
stat: StatData,
/// True if the file was modified within `RACY_WINDOW` of this snapshot. A
/// file's timestamp advances only every few milliseconds -- every 2
/// seconds, on FAT -- so a write which follows this snapshot within the
/// same tick, without changing the file's length, changes its contents
/// while leaving its metadata unchanged. A racy stamp therefore never
/// matches, forcing the file to be read again; the stamp taken then is
/// trustworthy once the file's last write is old enough. Git calls such
/// entries "racily clean".
is_racy: bool,
}
/// The parts of a file's metadata which change when its contents do. Unix
/// supplies the fields Git compares. On Windows, the change time and the file
/// index which would stand in for Unix's change time and inode are available
/// only in nightly Rust, leaving just the length and modification time. The
/// creation time can't take their place: NTFS caches a file's creation time for
/// a short period after the file is deleted or renamed away, then gives it to a
/// new file which takes that name -- precisely what an editor does when it
/// saves by writing a temporary file, then renaming it over the original.
/// Windows calls this file system tunneling; see the remarks in
/// [`FileSystemInfo.CreationTime`](https://learn.microsoft.com/en-us/dotnet/api/system.io.filesysteminfo.creationtime#remarks)
/// and Raymond Chen's
/// [The apocryphal history of file system tunnelling](https://devblogs.microsoft.com/oldnewthing/20050715-14/?p=34923).
#[derive(PartialEq, Eq)]
struct StatData {
len: u64,
/// The modification time; `None` if the platform doesn't report one. Two
/// `None`s compare equal, which would report every same-length edit as no
/// change, so `FileStamp::new` marks a stamp without a modification time as
/// racy, which keeps it from ever matching. Compare these times only for
/// equality, never order:
/// [`SystemTime`](https://doc.rust-lang.org/std/time/struct.SystemTime.html)
/// isn't monotonic, so a file saved after another may carry an earlier
/// time.
modified: Option<SystemTime>,
/// The change time as (seconds, nanoseconds). Unlike the modification time,
/// which `touch -d`, `cp -p`, `rsync -t`, and archive extraction set to
/// arbitrary values, no program can set it.
#[cfg(unix)]
ctime: (i64, i64),
/// The device and inode numbers, which change when an editor saves by
/// renaming a new file over the old one.
#[cfg(unix)]
dev_ino: (u64, u64),
}
/// Everything the cache knows about one id: where (if anywhere) it's defined,
/// and which files must be rebuilt when that changes.
#[derive(Default)]
pub(super) struct IdEntry {
/// The relationship between this id and the `Target`/gather element or
/// `Fragment` which defines it.
pub(super) state: IdState,
/// Every file whose rendered output depends on this id. Note that this does
/// *not* capture the reverse dependency of a `Fragment` on the gather
/// elements which list it; see <xref ref="cc-P-g5o100jN"></xref> and
/// `commit_file`.
pub(super) dependents: HashSet<PathBuf>,
}
/// Given an id, this enumerates the possible relationships between that id and
/// the `Target`/gather element or `Fragment` which defines it.
#[derive(Debug, Default, PartialEq, Eq)]
pub(super) enum IdState {
/// The expected relationship: this `id` maps to exactly one `Target`/gather
/// element or `Fragment`.
Single(PathBuf),
/// Duplicate id: one id maps to multiple `Target`s/gather elements or
/// `Fragment`s; the keys give a list of a files in which that id is
/// defined, while the value indicates the number of definitions in that
/// file.
Multiple(HashMap<PathBuf, u32>),
/// Ids that appeared in cross-references or gather elements but aren't
/// defined by any file.
#[default]
Missing,
}
/// The status of a file from the cache's perspective.
#[derive(Debug, Default, PartialEq, Eq)]
pub(super) enum FileStatus {
/// The file hasn't been processed: an entry's state until its first commit.
#[default]
Unknown,
/// The file needs to be re-processed to update cross-references or gather
/// elements.
Outdated,
/// The file has been processed.
UpToDate,
}
/// Contains all information about a target: any HTML element with an id other
/// than a `<fragment>`, which is a `Fragment` instead. The id (which should be
/// globally unique within the project; duplicate ids are flagged as errors) is
/// the key of `FileEntry::targets`, and the containing file is the entry
/// holding this value, so neither is duplicated here.
pub(super) struct Target {
/// The inner HTML of this element.
pub(super) inner_html: String,
/// The ids gathered, in `data-gather` order, if this is a gather element;
/// empty otherwise.
pub(super) gather_ids: Vec<String>,
/// The line number of this target in its file. Always 0 until the
/// pulldown-cmark HTML writer preserves line numbers. TODO: when the URL
/// given to the Client includes an anchor, the Client must use the anchor
/// to find the corresponding Target (the anchor is the id), then use this
/// field to identify the line on which that id resides in order to scroll
/// to the appropriate location in the document.
pub(super) _line: usize,
}
/// This defines a cross reference to a `Target`.
pub(super) struct Xref {
/// The id cross-referenced.
pub(super) id: String,
}
/// This is a unique id that encompasses a series of code/doc blocks, always
/// starting with a doc block, which `GatherElement`s operate on. As with
/// `Target`, the id is the key of `FileEntry::fragments` and the containing
/// file is the entry holding this value.
pub(super) struct Fragment {
/// The code/doc block content of this element rendered as HTML. This is
/// empty until the fragment's doc blocks are finalized and the caller
/// stores the result via `Cache::update_fragment_content`.
pub(super) content: String,
/// The line number of this `Fragment` in its file; see `Target::line`.
pub(super) _line: usize,
}
// ### Facts
//
// Plain data collected while walking a file's DOM, then applied to the cache in
// a single transaction by `Cache::commit_file`. Keeping these free of DOM types
// lets the walk run without holding the cache lock.
/// All cacheable facts found in one file.
#[derive(Default)]
pub(super) struct FileFacts {
/// Every element with an id (excluding fragments), in document order.
/// Gather elements appear here too, distinguished by a non-empty
/// `TargetFact::gather_ids`.
pub(super) targets: Vec<TargetFact>,
/// The destination id of every cross-reference, in document order.
pub(super) xrefs: Vec<String>,
/// Every fragment, in document order.
pub(super) fragments: Vec<FragmentFact>,
}
/// A target found in the DOM; see `Target` for field documentation.
pub(super) struct TargetFact {
/// The id of this target.
pub(super) id: String,
pub(super) inner_html: String,
pub(super) line: usize,
/// The index of the doc block which contains this `Target` in the vec of
/// `CodeDocBlock`s for this file. This is pass-local -- the cache doesn't
/// store it -- but the caller needs it to place hydrated content.
pub(super) _doc_block_index: usize,
/// The ids listed by this element's `data-gather` attribute; empty if this
/// isn't a gather element.
pub(super) gather_ids: Vec<String>,
}
/// A fragment found in the DOM; see `Fragment` for field documentation. The
/// fragment's content isn't known during the walk (doc blocks aren't finalized
/// yet), so it's stored later via `Cache::update_fragment_content`.
pub(super) struct FragmentFact {
/// The id of this fragment.
pub(super) id: String,
pub(super) line: usize,
/// The index of the first doc block of this `Fragment` in the vec of
/// `CodeDocBlock`s for this file.
pub(super) _doc_block_start_index: usize,
/// The index of the last code/doc block of this `Fragment` in the vec of
/// `CodeDocBlock`s for this file.
pub(super) _code_doc_block_end_index: usize,
}
// ### Commit results
/// The result of committing one file's facts to the cache.
pub(super) struct CommitOutcome {
/// Files (other than the committed file) whose rendered output is
/// invalidated by this commit. Their status has already been set to
/// `Outdated`, and each has been passed on to be re-rendered: to the
/// connections showing it, or else to the consumer; see
/// `Cache::mark_outdated`.
#[cfg(test)]
pub(super) outdated: HashSet<PathBuf>,
}
/// The result of looking up an id; borrows from the cache, so it must be used
/// while the cache lock is still held.
pub(super) enum IdResolution<'a> {
/// The id names a target.
Target {
/// The file defining the target.
path: &'a Path,
target: &'a Target,
},
/// The id names a fragment.
Fragment {
/// The file defining the fragment.
path: &'a Path,
fragment: &'a Fragment,
},
/// No file defines this id.
Missing,
/// The id is defined more than once: a map from each defining file to the
/// number of definitions it contains. Carrying the counts lets the caller
/// report a duplicate within a single file.
///
/// TODO: this is currently unused.
#[allow(dead_code)]
Multiple(&'a HashMap<PathBuf, u32>),
}
/// One gather element which lists a given fragment, as returned by
/// `Cache::gathers_referencing`; this is everything needed to render one of the
/// backlinks a `<fragment>` hydrates to.
pub(super) struct GatherBacklink<'a> {
/// The file containing the gather element, which the backlink points into.
pub(super) path: &'a Path,
/// The gather element's id, which is the anchor the backlink points to.
pub(super) id: &'a str,
/// The gather element itself; its `inner_html` is the backlink's text.
pub(super) target: &'a Target,
}
// ### Watcher, walker, and consumer
//
// The watcher reports changed paths to the walker, which forwards those its
// ignore files don't exclude -- and the files found by walking each directory
// reported -- to the consumer, which applies them to the cache. See `Cache
// population and maintenance` in the spec.
/// A path reported by the watcher. A rename is reported as the deletion of its
/// old path followed by the creation of its new one.
#[derive(Debug, PartialEq, Eq)]
pub(super) enum WatcherPathState {
/// A file which was created or modified, or a directory which was created.
CreatedOrModified(PathBuf),
/// A file or directory which was deleted.
Deleted(PathBuf),
}
/// Messages sent to the consumer.
#[derive(Debug, PartialEq, Eq)]
pub(super) enum ConsumerPathState {
/// A path from the walker: a file a walk found, or a path the watcher
/// reported.
WatcherPathState(WatcherPathState),
/// The walker is about to walk this directory. Every file the walk finds
/// arrives before the matching `WalkFinished`.
WalkStarted(PathBuf),
/// The walk announced by the preceding `WalkStarted` is complete.
WalkFinished,
/// A request to bring a file the cache already has up to date: a commit
/// outdated its rendered output (see `Cache::mark_outdated`), or a
/// connection stopped showing it (see `ConnectionRegistration::drop`).
/// Unlike a path from the walker, this is no evidence that the file isn't
/// ignored, so it can't keep a walk in progress from forgetting the file,
/// and is moot once the cache has forgotten it.
Rescan(PathBuf),
}
// ### Connections
//
// A connection's translation task owns its current file; the cache keeps only a
// copy, refreshed by the task each time that file changes. See `Connections` in
// the cache spec.
/// One connection whose current file lies in this project.
struct ConnectionView {
/// The file shown in the connection's Client, canonicalized to match the
/// paths the cache stores.
current_file: PathBuf,
/// Where to notify the connection. This is unbounded, since the cache lock
/// is held while sending.
notify_tx: UnboundedSender<CacheNotification>,
}
/// Notifications the cache sends to a registered connection.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CacheNotification {
/// A change elsewhere in the project outdated the rendered output of the
/// connection's current file, which the connection should re-translate.
CurrentFileOutdated,
/// A change elsewhere in the project outdated the rendered output of the
/// project's TOC, which every connection in the project shows in its
/// sidebar; the connection should re-render it.
TocOutdated,
/// The contents of the TOC changed on disk, or the TOC was deleted; the
/// connection should re-load then re-render it. (Since the IDE is editing
/// the current file, changes made to it on disk are superseded by the IDE
/// content, so that a `CurrentFileChanged` message is meaningless.)
TocChanged,
}
/// Lists a connection in `Cache::connections` for as long as this value lives;
/// dropping it removes the listing.
pub struct ConnectionRegistration {
/// The cache the connection is listed in. This is weak, so that only the
/// `CacheMap` keeps a cache alive; the map in turn never evicts a cache
/// while a connection is listed in it.
cache: Weak<Mutex<Cache>>,
/// The connection's key in `Cache::connections`.
connection_id: String,
}
// Code
// ----
/// The capacity of the `CacheMap` when the Server has the given number of
/// connections; `translation_task` applies this as connections come and go. Per
/// the spec, this balances the memory the caches consume against the time
/// needed to load the cache of a project which isn't cached.
#[must_use]
pub fn cache_map_capacity(
// The number of connections,
// <span class="math math-inline mceNonEditable">\\(n\\)</span>.
connections: usize,
// <span class="math math-inline mceNonEditable">\\(n c + 1\\)</span>, where
// <span class="math math-inline mceNonEditable">\\(c\\)</span> is
// `LRU_ENTRIES_PER_CONNECTION`.
) -> NonZeroUsize {
NonZeroUsize::MIN.saturating_add(connections.saturating_mul(LRU_ENTRIES_PER_CONNECTION))
}
/// Return the cache of the project rooted at `root`, first creating it if the
/// `CacheMap` has none.
pub fn get_or_insert_cache(
// The contents of the locked `CacheMap`.
caches: &mut ProjectCaches,
// The project's root directory, as a canonicalized absolute path.
root: &Path,
// Creates the project's cache, if the map has none.
create: impl FnOnce() -> Arc<Mutex<Cache>>,
// The project's cache.
) -> Arc<Mutex<Cache>> {
if let Some(cache) = caches.get(root) {
return cache.clone();
}
// Inserting into a full map evicts its least recently used cache, even one
// with connections, so make room first.
evict_unconnected(caches, caches.cap().get() - 1);
if caches.len() == caches.cap().get() {
caches.resize(caches.cap().saturating_add(1));
}
let cache = create();
caches.put(root.to_path_buf(), cache.clone());
cache
}
/// Set the capacity of the `CacheMap`; `translation_task` applies
/// `cache_map_capacity` as connections come and go.
pub fn set_cache_map_capacity(
// The contents of the locked `CacheMap`.
caches: &mut ProjectCaches,
// The new capacity.
capacity: NonZeroUsize,
) {
evict_unconnected(caches, capacity.get());
// Shrinking the map evicts its least recently used caches, even those with
// connections, so keep room for any which remain.
caches.resize(capacity.max(NonZeroUsize::new(caches.len()).unwrap_or(capacity)));
}
/// Evict the least recently used caches in which no connection is registered,
/// until the `CacheMap` holds no more than `capacity` caches. Per the spec, a
/// cache with connections is never evicted: a `ConnectionRegistration` holds
/// only a weak reference to its cache, so evicting the cache would drop it
/// while its connections still depend on it. Since a connection registers with
/// at most one cache, and the map's capacity exceeds the number of connections
/// (see `cache_map_capacity`), this normally leaves the map within `capacity`.
fn evict_unconnected(
// The contents of the locked `CacheMap`.
caches: &mut ProjectCaches,
// The number of caches to keep, if possible.
capacity: usize,
) {
let excess = caches.len().saturating_sub(capacity);
let evicted: Vec<PathBuf> = caches
.iter()
.rev()
.filter(|(_, cache)| {
cache
.lock()
.unwrap_or_else(PoisonError::into_inner)
.connections
.is_empty()
})
.map(|(root, _)| root.clone())
.take(excess)
.collect();
for root in evicted {
caches.pop(&root);
}
}
impl Cache {
/// Create an empty cache with no consumer to keep it current: the cache of
/// a single-file "project", which consists only of that file, or of a test.
/// A project's cache comes from `Cache::new_project`.
#[must_use]
pub fn new(
// The root directory of this project, as a canonicalized absolute path;
// all cached files must live within it. For a single-file "project",
// this is that file.
root: PathBuf,
// The empty cache.
) -> Self {
Cache {
files: BTreeMap::new(),
ids: HashMap::new(),
root,
consumer_tx: None,
_watcher: None,
connections: HashMap::new(),
}
}
/// Create the cache of a project, then start the threads which keep it
/// current: a watcher thread, which registers a watcher to report changes
/// to the project's files, then becomes the walker, which filters those
/// reports through the project's ignore files; and a consumer, which
/// applies the walker's results to the cache. The walker's first task is a
/// walk of the entire project, so that the consumer brings every file in it
/// into the cache. See `Cache population and maintenance` in the spec.
#[must_use]
pub fn new_project(
// The project's root directory, as a canonicalized absolute path; all
// cached files must live within it.
root: PathBuf,
// The cache, shared with the threads which keep it current.
) -> Arc<Mutex<Self>> {
let (consumer_tx, consumer_rx) = mpsc::channel();
let walker_consumer_tx = consumer_tx.clone();
let cache = Arc::new(Mutex::new(Cache {
consumer_tx: Some(consumer_tx),
..Cache::new(root.clone())
}));
// Give these threads only weak references, so that they don't keep the
// cache alive; see `Shutdown` in the spec.
let weak_cache = Arc::downgrade(&cache);
thread::spawn(move || consume(&weak_cache, &consumer_rx));
let weak_cache = Arc::downgrade(&cache);
thread::spawn(move || watch_then_walk(&weak_cache, root, walker_consumer_tx));
cache
}
/// <h3 id="cc-MtbstS0OHd">commit_file</h3>
///
/// Apply the facts collected
/// from one file's DOM walk to the cache in a single transaction. This
/// satisfies several requirements:
///
/// * Determine if any files containing cross-references need to be rebuilt
/// due to changes in the `Target`s in this file: any target which was
/// added, deleted, or modified marks its dependent files outdated. Note
/// that "modified" refers only to the `Target` state that
/// cross-references depend on (its id and inner HTML).
/// * Determine if any files containing gather elements need to be rebuilt
/// due to changes in the `Fragment`s in this file. Fragment additions and
/// deletions are handled here; content changes are detected by
/// `update_fragment_content`, since a fragment's rendered content is only
/// known after doc block processing completes.
/// * Determine if any files defining a gathered `Fragment` need to be
/// rebuilt due to changes in the gather elements which list it -- the
/// reverse edge described below.
///
/// Because cross-references carry no id to match them against their
/// previous versions, the diff instead unlinks all of the old version's
/// outgoing references, then links all of the new version's. For
/// cross-references this causes no spurious rebuilds, because their
/// dependency sets are only ever mutated by that round trip and never used
/// to detect change: the target's file renders nothing that depends on who
/// references it.
///
/// A gather element's outgoing references are different, and cannot be
/// maintained that way. Each fragment it lists renders a backlink to it, so
/// the reference set *is* observable output in the fragment's file, and the
/// round trip would erase the information needed to detect a change in it.
/// The old and new `gather_ids` are therefore diffed explicitly, before the
/// old ones are overwritten, and the file defining each affected fragment
/// is outdated:
///
/// * If the gather element's inner HTML changed (including when the element
/// was added or deleted, which counts as a change to or from "no inner
/// HTML"), every id in the union of the old and new lists is affected --
/// that inner HTML is the text of the backlink each of them renders.
/// * Otherwise only the symmetric difference is affected: the fragments
/// which gained or lost this backlink.
///
/// See <xref ref="cc-P-g5o100jN"></xref> for why this doesn't cycle back.
#[allow(clippy::too_many_lines)]
pub(super) fn commit_file(
&mut self,
// The file whose facts these are.
path: &Path,
// The file's stamp, taken when its content was read; `None` if its
// metadata is unavailable. Take this *before* locking the cache, so no
// I/O happens while the lock is held.
stamp: Option<FileStamp>,
// The facts collected from the file's DOM.
facts: FileFacts,
// The outcome: outdated files. Reprocessing these outdated files may
// reveal additional outdated files.
) -> CommitOutcome {
let mut outdated: HashSet<PathBuf> = HashSet::new();
// ### Take the old state
//
// Move the previous version out of the entry (creating the entry if
// this is the first commit for this file), so the new version can be
// diffed against it. Everything the diff needs must be read from these
// locals; the cache no longer holds the old version.
let entry = self.files.entry(path.to_path_buf()).or_default();
let old_targets = mem::take(&mut entry.targets);
let old_fragments = mem::take(&mut entry.fragments);
let old_gathers = mem::take(&mut entry.gathers);
let old_xrefs = mem::take(&mut entry.xrefs);
// ### Unlink the old version's outgoing references
//
// Remove this file from the dependents of every id its previous version
// referenced; the new version's references are linked below. This is
// only bookkeeping -- the gather diff further below reads
// `old_targets`, not these sets.
let old_referenced_ids: Vec<String> = old_xrefs
.iter()
.map(|xref| xref.id.clone())
.chain(gathered_ids(&old_gathers, &old_targets).cloned())
.collect();
for id in &old_referenced_ids {
self.unlink_reference(id, path);
}
// ### Build the new definitions
//
// Targets and fragments share one id namespace, so definitions of both
// are counted together; the count is what distinguishes
// `IdState::Single` from `IdState::Multiple`. A duplicate within this
// file has nowhere to live in the id-keyed maps, so the first
// definition in document order is the one stored; every reference to a
// duplicated id renders an error, so which one is stored only matters
// once the duplicate is resolved, at which point this file is
// recommitted anyway.
let mut new_counts: HashMap<String, u32> = HashMap::new();
let mut new_targets: HashMap<String, Target> = HashMap::with_capacity(facts.targets.len());
let mut new_gathers: Vec<String> = Vec::new();
for fact in facts.targets {
*new_counts.entry(fact.id.clone()).or_default() += 1;
if new_targets.contains_key(&fact.id) {
continue;
}
if !fact.gather_ids.is_empty() {
new_gathers.push(fact.id.clone());
}
new_targets.insert(
fact.id,
Target {
inner_html: fact.inner_html,
gather_ids: fact.gather_ids,
_line: fact.line,
},
);
}
let mut new_fragments: HashMap<String, Fragment> =
HashMap::with_capacity(facts.fragments.len());
for fact in facts.fragments {
*new_counts.entry(fact.id.clone()).or_default() += 1;
if new_fragments.contains_key(&fact.id) {
continue;
}
// A surviving fragment keeps its old content until the caller
// supplies the new content via `update_fragment_content`, which
// also detects content changes.
let content = old_fragments
.get(&fact.id)
.map_or_else(String::new, |old_fragment| old_fragment.content.clone());
new_fragments.insert(
fact.id,
Fragment {
content,
_line: fact.line,
},
);
}
// The set of ids the new version references, saved before `new_targets`
// is moved into the entry; used to link references below.
let new_refs: HashSet<String> = facts
.xrefs
.iter()
.cloned()
.chain(gathered_ids(&new_gathers, &new_targets).cloned())
.collect();
// ### Update the definition side of `ids`
//
// For every id this file defined before or defines now, replace this
// file's contribution to the id's definition map. Any id whose
// `IdState` changes -- appearing, disappearing, moving, or
// becoming/ceasing to be a duplicate -- outdates every file which
// references it.
let affected_ids: HashSet<&String> = old_targets
.keys()
.chain(old_fragments.keys())
.chain(new_counts.keys())
.collect();
// The ids this commit left without any definition, which are forgotten
// below unless something references them.
let mut undefined_ids: Vec<String> = Vec::new();
for id in affected_ids {
let id_entry = self.ids.entry(id.clone()).or_default();
let mut definitions = id_entry.state.definitions();
match new_counts.get(id) {
Some(&count) => definitions.insert(path.to_path_buf(), count),
None => definitions.remove(path),
};
let new_state = IdState::from_definitions(definitions);
if id_entry.state != new_state {
id_entry.state = new_state;
outdated.extend(id_entry.dependents.iter().cloned());
}
if id_entry.state == IdState::Missing {
undefined_ids.push(id.clone());
}
}
// ### Outdate the dependents of changed definitions
//
// `IdState` can't see two changes which leave the defining file the
// same: a target's inner HTML changing, and an id changing kind between
// target and fragment (which turns a cross-reference into an error, or
// back). A fragment's content isn't known yet;
// `update_fragment_content` handles it.
let changed_ids = new_targets
.iter()
.filter(|(id, new_target)| {
old_targets.get(*id).map_or_else(
|| old_fragments.contains_key(*id),
|old_target| old_target.inner_html != new_target.inner_html,
)
})
.map(|(id, _)| id)
.chain(
new_fragments
.keys()
.filter(|id| old_targets.contains_key(*id)),
);
let mut changed_dependents: HashSet<PathBuf> = HashSet::new();
for id in changed_ids {
if let Some(id_entry) = self.ids.get(id) {
changed_dependents.extend(id_entry.dependents.iter().cloned());
}
}
outdated.extend(changed_dependents);
// ### Outdate the files defining fragments this file gathers
//
// The reverse edge; see this function's documentation. Outdate the
// file(s) defining each fragment whose backlinks changed. An id with no
// definition needs nothing: whenever it does appear, the file defining
// it renders its own backlinks in that same pass.
let mut regathered_files: HashSet<PathBuf> = HashSet::new();
for id in regathered_ids(&old_gathers, &old_targets, &new_gathers, &new_targets) {
match self.ids.get(id).map(|id_entry| &id_entry.state) {
Some(IdState::Single(owner)) => {
regathered_files.insert(owner.clone());
}
Some(IdState::Multiple(definitions)) => {
regathered_files.extend(definitions.keys().cloned());
}
Some(IdState::Missing) | None => {}
}
}
outdated.extend(regathered_files);
// ### Store the new state
//
// Note that ids auto-assigned during this pass are deliberately absent
// from `facts`: they live only in the HTML sent to the Client until the
// file is written and re-read (see `Auto-assignment of ids`), at which
// point they arrive here like any other id.
let entry = self.files.get_mut(path).expect("entry was created above");
entry.stamp = stamp;
entry.targets = new_targets;
entry.fragments = new_fragments;
entry.gathers = new_gathers;
entry.xrefs = facts.xrefs.into_iter().map(|id| Xref { id }).collect();
entry.status = FileStatus::UpToDate;
// ### Link the new version's outgoing references
//
// Add this file to the dependents of every id it references, creating a
// `Missing` entry for ids nothing defines yet.
for id in new_refs {
self.ids
.entry(id)
.or_default()
.dependents
.insert(path.to_path_buf());
}
// ### Forget unused ids
//
// Only now, with this file's references linked, is it known which of
// the ids it stopped defining nothing references.
for id in &undefined_ids {
self.remove_if_unused(id);
}
// ### Mark outdated files
//
// A file never outdates itself: its rendered output was just produced
// from the state committed here.
outdated.remove(path);
self.mark_outdated(&outdated);
CommitOutcome {
#[cfg(test)]
outdated,
}
}
/// Remove everything the cache knows about a file -- when the file is
/// deleted or moved out of the project, for example. Committing an empty
/// set of facts unlinks every reference the file made and withdraws every
/// definition it provided, outdating the files which depended on them; the
/// file's now-empty entry is then dropped.
pub(super) fn remove_file(
&mut self,
// The file to forget.
path: &Path,
// The outcome: files outdated by the removal.
) -> CommitOutcome {
let outcome = self.commit_file(path, None, FileFacts::default());
self.files.remove(path);
outcome
}
/// Generate an id for an element which requests one (`id="*"`). The result
/// is deliberately *not* recorded in the cache: it enters the cache only
/// once the file holding it is written and re-read, so that a write which
/// never happens (or fails) can't leave the cache describing an id no file
/// contains. See `Auto-assignment of ids`.
pub(super) fn new_id(
&self,
// Ids already assigned during this pass, which aren't in the cache and
// so can't be found there.
assigned: &HashSet<String>,
// A random id which collides with no id the cache knows of, and which
// is a valid CSS identifier.
) -> String {
// The characters a generated id may contain; all are valid in a CSS
// identifier. There are exactly 64 of them, so each character consumes
// 6 bits of randomness with no modulo bias.
const ID_CHARS: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
// The number of random characters in a generated id. At 6 bits each,
// these fit in the 64 bits a single `random` call provides.
const ID_LEN: usize = 10;
loop {
// The `cc-` prefix keeps the identifier from starting with a digit
// or a hyphen, neither of which CSS identifiers may do.
let mut id = String::from("cc-");
let mut bits = random::<u64>();
for _ in 0..ID_LEN {
id.push(ID_CHARS[(bits & 0x3f) as usize] as char);
bits >>= 6;
}
if !self.ids.contains_key(&id) && !assigned.contains(&id) {
return id;
}
}
}
/// Store a fragment's rendered content, once the caller has finalized its
/// doc blocks. If the content changed, all files containing gather elements
/// which reference the fragment are marked outdated.
pub(super) fn update_fragment_content(
&mut self,
// The file containing the fragment.
path: &Path,
// The fragment's id.
id: &str,
// The fragment's code/doc block content, rendered as HTML.
content: String,
// The files (other than `path`) marked outdated by this change, as
// described by `CommitOutcome::outdated`.
) -> HashSet<PathBuf> {
// Apply the new content first, so that no borrow of `self.files`
// outlives the check; report whether it differed from the old content.
let changed = self
.files
.get_mut(path)
.and_then(|entry| entry.fragments.get_mut(id))
.is_some_and(|fragment| {
let changed = fragment.content != content;
if changed {
fragment.content = content;
}
changed
});
if !changed {
return HashSet::new();
}
let mut outdated = self
.ids
.get(id)
.map_or_else(HashSet::new, |id_entry| id_entry.dependents.clone());
// Gather elements in the fragment's own file are updated by the caller
// in the same processing pass.
outdated.remove(path);
self.mark_outdated(&outdated);
outdated
}
/// <h3 id="cc-MxbstS0OHd">mark_outdated</h3>
///
/// Mark files outdated, then see that each is re-rendered. A connection
/// re-renders a file it shows -- its current file, or the TOC in its
/// sidebar, which every connection in the project shows -- since the text
/// it shows may be newer than the file on disk; the consumer reprocesses
/// the rest from disk.
fn mark_outdated(
&mut self,
// The files to mark.
outdated: &HashSet<PathBuf>,
) {
for path in outdated {
if let Some(entry) = self.files.get_mut(path) {
entry.status = FileStatus::Outdated;
}
}
self.notify_current_file_outdated(outdated);
if outdated.contains(&self.toc_path()) {
self.notify_toc(CacheNotification::TocOutdated);
}
if let Some(consumer_tx) = &self.consumer_tx {
for path in outdated.iter().filter(|path| !self.is_shown(path)) {
// A send fails only once the consumer has exited, when this
// cache is being dropped and so needs no more updates.
let _ = consumer_tx.send(ConsumerPathState::Rescan(path.clone()));
}
}
}
/// Notify each connection whose current file is one of these files, so that
/// the connection re-translates it. Several connections may share a current
/// file, if it's being edited in each simultaneously.
fn notify_current_file_outdated(
&self,