Skip to main content

isml_lsp/
errors.rs

1//! Pending SFCC error count from log-diff, shown via LSP progress (the only channel Zed renders for an extension).
2
3use crossbeam_channel::{SendError, Sender};
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use lsp_server::Message;
8use lsp_types::notification::{Notification, Progress};
9use lsp_types::request::{Request as RequestTrait, WorkDoneProgressCreate};
10use lsp_types::{
11    NumberOrString, ProgressParams, ProgressParamsValue, WorkDoneProgress, WorkDoneProgressBegin,
12    WorkDoneProgressCreateParams, WorkDoneProgressEnd, WorkDoneProgressReport,
13};
14
15const TOKEN: &str = "sfcc/errors";
16const POLL: Duration = Duration::from_secs(5);
17
18pub use sfcc_core::state::errors::Status;
19use sfcc_core::state::{errors, is_within, now_seconds};
20
21/// `errors` in the initialization options.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub struct Options {
24    /// `log-diff start` for a workspace with a dw.json. Off by default.
25    pub autostart: bool,
26}
27
28impl Options {
29    pub fn from_settings(settings: Option<&serde_json::Value>) -> Options {
30        Options {
31            autostart: settings
32                .and_then(|settings| settings.get("errors"))
33                .and_then(|errors| errors.get("autostart"))
34                .and_then(serde_json::Value::as_bool)
35                .unwrap_or(false),
36        }
37    }
38}
39
40pub fn report(roots: Vec<PathBuf>, options: Options, sender: Sender<Message>) {
41    std::thread::spawn(move || {
42        if options.autostart {
43            for (level, text) in crate::sync::autostart(&roots, "log-diff", "errors") {
44                if crate::sync::show(&sender, level, text).is_err() {
45                    return;
46                }
47            }
48        }
49        if create_token(&sender).is_err() {
50            return;
51        }
52        let mut shown: Option<String> = None;
53        loop {
54            let message = current(&roots).and_then(|status| describe(&status));
55            if message != shown && announce(&sender, shown.is_some(), message.clone()).is_err() {
56                return;
57            }
58            shown = message;
59            std::thread::sleep(POLL);
60        }
61    });
62}
63
64/// The freshest status for a sandbox whose checkout is one of the open folders.
65pub fn current(roots: &[PathBuf]) -> Option<Status> {
66    let mut best: Option<Status> = None;
67    for status in errors::all() {
68        if !covers(&status, roots) || now_seconds() - status.at > errors::STALE_SECONDS {
69            continue;
70        }
71        if best.as_ref().is_none_or(|found| found.at < status.at) {
72            best = Some(status);
73        }
74    }
75    best
76}
77
78fn covers(status: &Status, roots: &[PathBuf]) -> bool {
79    let checked = Path::new(&status.cartridges);
80    roots.iter().any(|root| is_within(checked, root))
81}
82
83pub fn describe(status: &Status) -> Option<String> {
84    let noun = if status.pending == 1 {
85        "error"
86    } else {
87        "errors"
88    };
89    match (status.pending, status.new) {
90        (0, _) => None,
91        (pending, 0) => Some(format!("{pending} SFCC {noun} pending - log-diff list")),
92        (pending, new) => Some(format!(
93            "{pending} SFCC {noun} pending ({new} new) - log-diff list"
94        )),
95    }
96}
97
98fn announce(
99    sender: &Sender<Message>,
100    was_shown: bool,
101    message: Option<String>,
102) -> Result<(), SendError<Message>> {
103    let value = match (was_shown, message) {
104        (false, Some(message)) => WorkDoneProgress::Begin(WorkDoneProgressBegin {
105            title: "SFCC".to_string(),
106            message: Some(message),
107            ..Default::default()
108        }),
109        (true, Some(message)) => WorkDoneProgress::Report(WorkDoneProgressReport {
110            message: Some(message),
111            ..Default::default()
112        }),
113        (true, None) => WorkDoneProgress::End(WorkDoneProgressEnd { message: None }),
114        (false, None) => return Ok(()),
115    };
116    sender.send(Message::Notification(lsp_server::Notification::new(
117        Progress::METHOD.to_string(),
118        ProgressParams {
119            token: NumberOrString::String(TOKEN.to_string()),
120            value: ProgressParamsValue::WorkDone(value),
121        },
122    )))
123}
124
125fn create_token(sender: &Sender<Message>) -> Result<(), SendError<Message>> {
126    sender.send(Message::Request(lsp_server::Request::new(
127        lsp_server::RequestId::from(TOKEN.to_string()),
128        WorkDoneProgressCreate::METHOD.to_string(),
129        WorkDoneProgressCreateParams {
130            token: NumberOrString::String(TOKEN.to_string()),
131        },
132    )))
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn status(pending: usize, new: usize) -> Status {
140        Status {
141            cartridges: "/repo/source/cartridges".into(),
142            hostname: "sbx-001.example.com".into(),
143            pending,
144            new,
145            at: 0,
146        }
147    }
148
149    #[test]
150    fn starts_log_diff_only_when_asked() {
151        let on = serde_json::json!({ "errors": { "autostart": true } });
152        assert!(Options::from_settings(Some(&on)).autostart);
153        let off = serde_json::json!({ "upload": { "autostart": true } });
154        assert!(!Options::from_settings(Some(&off)).autostart);
155        assert!(!Options::from_settings(None).autostart);
156    }
157
158    #[test]
159    fn reads_what_log_diff_writes() {
160        let found: Status = serde_json::from_str(
161            r#"{"cartridges":"/repo/cartridges","hostname":"h","pending":2,"new":1,"at":1700000000}"#,
162        )
163        .unwrap();
164        assert_eq!(found.pending, 2);
165        assert_eq!(found.new, 1);
166    }
167
168    #[test]
169    fn says_something_only_while_something_is_pending() {
170        assert_eq!(describe(&status(0, 0)), None);
171        assert_eq!(
172            describe(&status(1, 0)).as_deref(),
173            Some("1 SFCC error pending - log-diff list")
174        );
175        assert_eq!(
176            describe(&status(3, 2)).as_deref(),
177            Some("3 SFCC errors pending (2 new) - log-diff list")
178        );
179    }
180
181    #[test]
182    fn claims_only_a_checkout_inside_an_open_folder() {
183        assert!(covers(&status(1, 0), &[PathBuf::from("/repo")]));
184        assert!(!covers(&status(1, 0), &[PathBuf::from("/elsewhere")]));
185    }
186}