Skip to main content

bynkc_lsp/
publish.rs

1//! v0.24 (ADR 0052): the project-wide publish plan — a pure function so the
2//! clear semantics are unit-tested without an LSP transport (the JSON-RPC
3//! harness is deferred to the first interactive feature; recorded in the
4//! v0.24 proposal).
5
6use std::collections::{HashMap, HashSet};
7
8use tower_lsp::lsp_types::{Diagnostic, Url};
9
10/// Compute the publishes for one analysis round: every URI with new
11/// diagnostics, **plus an empty publish for every URI that carried
12/// diagnostics last round and no longer does** (the clear). Returns the
13/// publish list and the next round's dirty set.
14pub fn publish_plan(
15    previously_dirty: &HashSet<Url>,
16    new_by_uri: HashMap<Url, Vec<Diagnostic>>,
17) -> (Vec<(Url, Vec<Diagnostic>)>, HashSet<Url>) {
18    let mut publishes: Vec<(Url, Vec<Diagnostic>)> = Vec::new();
19    let mut dirty: HashSet<Url> = HashSet::new();
20    for (uri, diags) in new_by_uri {
21        if !diags.is_empty() {
22            dirty.insert(uri.clone());
23            publishes.push((uri, diags));
24        } else if previously_dirty.contains(&uri) {
25            // Newly clean: clear.
26            publishes.push((uri, Vec::new()));
27        }
28    }
29    // Previously-dirty files that vanished from the analysis entirely
30    // (deleted, renamed) also clear.
31    let analysed: HashSet<&Url> = publishes.iter().map(|(u, _)| u).collect();
32    let mut gone: Vec<Url> = previously_dirty
33        .iter()
34        .filter(|u| !dirty.contains(*u) && !analysed.contains(u))
35        .cloned()
36        .collect();
37    gone.sort_by(|a, b| a.as_str().cmp(b.as_str()));
38    for uri in gone {
39        publishes.push((uri, Vec::new()));
40    }
41    (publishes, dirty)
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47
48    fn uri(s: &str) -> Url {
49        Url::parse(&format!("file:///{s}")).unwrap()
50    }
51    fn diag(msg: &str) -> Diagnostic {
52        Diagnostic {
53            message: msg.to_string(),
54            ..Default::default()
55        }
56    }
57
58    #[test]
59    fn publishes_new_and_clears_fixed() {
60        let prev: HashSet<Url> = [uri("a.bynk"), uri("b.bynk")].into_iter().collect();
61        let mut new = HashMap::new();
62        new.insert(uri("a.bynk"), vec![diag("still broken")]);
63        new.insert(uri("b.bynk"), vec![]); // fixed
64        new.insert(uri("c.bynk"), vec![]); // was never dirty — no publish
65
66        let (publishes, dirty) = publish_plan(&prev, new);
67        let by: HashMap<_, _> = publishes
68            .iter()
69            .map(|(u, d)| (u.clone(), d.len()))
70            .collect();
71        assert_eq!(by.get(&uri("a.bynk")), Some(&1), "still-broken republished");
72        assert_eq!(
73            by.get(&uri("b.bynk")),
74            Some(&0),
75            "fixed file gets an empty publish"
76        );
77        assert!(
78            !by.contains_key(&uri("c.bynk")),
79            "never-dirty clean file is not published"
80        );
81        assert!(dirty.contains(&uri("a.bynk")) && !dirty.contains(&uri("b.bynk")));
82    }
83
84    #[test]
85    fn vanished_files_clear() {
86        let prev: HashSet<Url> = [uri("gone.bynk")].into_iter().collect();
87        let (publishes, dirty) = publish_plan(&prev, HashMap::new());
88        assert_eq!(publishes, vec![(uri("gone.bynk"), Vec::new())]);
89        assert!(dirty.is_empty());
90    }
91
92    #[test]
93    fn newly_broken_file_enters_the_dirty_set() {
94        let prev = HashSet::new();
95        let mut new = HashMap::new();
96        new.insert(uri("a.bynk"), vec![diag("boom")]);
97        let (publishes, dirty) = publish_plan(&prev, new);
98        assert_eq!(publishes.len(), 1);
99        assert!(dirty.contains(&uri("a.bynk")));
100    }
101}