slang_ui/
lib.rs

1mod monaco;
2
3use std::{
4    collections::BTreeMap,
5    path::PathBuf,
6    sync::{Arc, RwLock},
7};
8
9use axum::{
10    extract::State,
11    http::{header, StatusCode, Uri},
12    response::{IntoResponse, Response},
13    routing::get,
14    Json, Router,
15};
16use clap::Parser;
17use color_eyre::eyre::Context as _;
18use itertools::Itertools;
19use rust_embed::Embed;
20use serde::{Deserialize, Serialize};
21use slang::{Position, SourceFile, Span};
22use tapi::endpoints::RouterExt;
23use tracing_subscriber::prelude::*;
24
25pub type Result<T, E = color_eyre::eyre::Error> = std::result::Result<T, E>;
26
27pub use color_eyre::eyre::{bail, eyre};
28
29pub mod prelude {
30    pub use super::Result;
31    pub use color_eyre::eyre::{bail, eyre};
32    pub use miette;
33    pub use slang;
34    pub use smtlib::{self, prelude::*};
35    pub use tracing;
36}
37
38#[non_exhaustive]
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
40pub enum Severity {
41    Info,
42    Warning,
43    Error,
44}
45
46#[non_exhaustive]
47#[derive(Debug, Clone, PartialEq)]
48pub struct Report {
49    pub severity: Severity,
50    pub span: Span,
51    pub message: String,
52}
53
54#[non_exhaustive]
55#[derive(Debug, Clone, Serialize, tapi::Tapi)]
56pub enum Color {
57    Red,
58    Green,
59    Blue,
60}
61
62pub struct Context {
63    storage: smtlib::Storage,
64    reports: RwLock<Vec<Report>>,
65    message: RwLock<Option<(String, Color)>>,
66}
67
68struct Logger {}
69
70impl smtlib::Logger for Logger {
71    fn exec(&self, cmd: smtlib::lowlevel::ast::Command) {
72        let span = tracing::span!(tracing::Level::INFO, "smt");
73        let _enter = span.enter();
74        tracing::info!("> {cmd}")
75    }
76
77    fn response(&self, _cmd: smtlib::lowlevel::ast::Command, res: &str) {
78        let span = tracing::span!(tracing::Level::INFO, "smt");
79        let _enter = span.enter();
80        tracing::info!("< {}", res.trim())
81    }
82}
83
84impl Context {
85    fn new(_file: &SourceFile) -> Context {
86        Context {
87            storage: smtlib::Storage::new(),
88            reports: Default::default(),
89            message: Default::default(),
90        }
91    }
92
93    pub fn smt_st(&self) -> &smtlib::Storage {
94        &self.storage
95    }
96
97    pub fn solver(&self) -> Result<smtlib::Solver<smtlib::backend::z3_binary::Z3Binary>> {
98        let mut solver = smtlib::Solver::new(
99            self.smt_st(),
100            smtlib::backend::z3_binary::Z3Binary::new("z3")
101                .context("failed to find `z3`. is it installed and in your path?")?,
102        )?;
103        solver.set_logger(Logger {});
104        solver.set_timeout(2_000)?;
105        Ok(solver)
106    }
107
108    pub fn report(&self, severity: Severity, span: Span, message: impl std::fmt::Display) {
109        let message = message.to_string();
110        self.reports.write().unwrap().push(Report {
111            severity,
112            span,
113            message,
114        })
115    }
116    pub fn info(&self, span: Span, message: impl std::fmt::Display) {
117        self.report(Severity::Info, span, message)
118    }
119    pub fn warning(&self, span: Span, message: impl std::fmt::Display) {
120        self.report(Severity::Warning, span, message)
121    }
122    pub fn error(&self, span: Span, message: impl std::fmt::Display) {
123        self.report(Severity::Error, span, message)
124    }
125
126    pub fn set_message(&self, message: impl std::fmt::Display, color: Color) {
127        *self.message.write().unwrap() = Some((message.to_string(), color))
128    }
129
130    pub fn reports(&self) -> Vec<Report> {
131        self.reports.read().unwrap().clone()
132    }
133
134    #[track_caller]
135    pub fn todo(&self, span: Span) {
136        let msg = format!("not yet implemented: {}", std::panic::Location::caller());
137        tracing::error!("{msg}");
138        self.warning(span, msg);
139    }
140}
141
142pub struct HoverResponse {
143    pub span: Span,
144    pub contents: Vec<String>,
145}
146
147pub trait Hook {
148    fn analyze(&self, cx: &Context, file: &SourceFile) -> Result<()>;
149    #[allow(unused_variables)]
150    fn hover(&self, cx: &Context, file: &SourceFile, pos: Position) -> Option<HoverResponse> {
151        None
152    }
153}
154
155#[derive(Embed)]
156#[folder = "static/"]
157struct Asset;
158
159pub async fn run(hook: impl Hook + Send + Sync + 'static) {
160    let _ = color_eyre::install();
161
162    tracing_subscriber::Registry::default()
163        .with(tracing_error::ErrorLayer::default())
164        .with(
165            tracing_subscriber::EnvFilter::builder()
166                .with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into())
167                .from_env_lossy(),
168        )
169        .with(
170            tracing_subscriber::fmt::layer()
171                .with_target(false)
172                .without_time(),
173        )
174        .with(tracing_subscriber::filter::FilterFn::new(|m| {
175            !m.target().contains("hyper")
176        }))
177        .init();
178
179    match run_impl(Arc::new(hook)).await {
180        Ok(()) => {}
181        Err(err) => println!("{err:?}"),
182    }
183}
184
185pub struct TestResult {
186    reports: Vec<Report>,
187    error: Option<color_eyre::eyre::Error>,
188}
189
190pub fn test(hook: impl Hook + 'static, src: &str) -> TestResult {
191    match run_hook(&hook, src) {
192        Ok(reports) => TestResult {
193            reports,
194            error: None,
195        },
196        Err((reports, error)) => TestResult {
197            reports,
198            error: Some(error),
199        },
200    }
201}
202
203impl TestResult {
204    pub fn has_message(&self, message: &str) -> bool {
205        self.reports.iter().any(|r| r.message == message)
206    }
207    pub fn has_errors(&self) -> bool {
208        self.error.is_some() || self.reports.iter().any(|r| r.severity == Severity::Error)
209    }
210    pub fn error(&self) -> Option<&color_eyre::eyre::Error> {
211        self.error.as_ref()
212    }
213    pub fn reports(&self) -> Vec<Report> {
214        self.reports.clone()
215    }
216}
217
218#[derive(clap::Parser)]
219struct Cli {
220    #[command(subcommand)]
221    command: Option<Command>,
222}
223
224#[derive(clap::Subcommand, Default, Clone)]
225enum Command {
226    #[default]
227    Ui,
228    Check {
229        #[clap(long, short, default_value = "human")]
230        format: OutputFormat,
231        path: PathBuf,
232    },
233}
234
235#[derive(Debug, Default, Clone, Copy, clap::ValueEnum)]
236enum OutputFormat {
237    #[default]
238    Human,
239    Json,
240}
241
242async fn run_impl(hook: Arc<dyn Hook + Send + Sync + 'static>) -> Result<()> {
243    let cli = Cli::parse();
244
245    match cli.command.clone().unwrap_or_default() {
246        Command::Ui => {
247            let endpoints = endpoints();
248
249            populate_js_client(&endpoints);
250
251            let app = Router::new()
252                .nest("/api", Router::new().tapis(endpoints.into_iter()))
253                .route("/", get(index_handler))
254                .route("/index.html", get(index_handler))
255                .route("/{*file}", get(static_handler))
256                .with_state(AppState { hook });
257
258            let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
259            axum::serve(listener, app).await.unwrap();
260
261            Ok(())
262        }
263        Command::Check { path, format } => {
264            let src = std::fs::read_to_string(&path)
265                .with_context(|| format!("failed to read '{}'", path.display()))?;
266
267            let reports = match run_hook(&*hook, &src) {
268                Ok(reports) => reports,
269                Err((_, error)) => return Err(error),
270            };
271
272            let diagnostics = reports.into_iter().map(|report| {
273                miette::diagnostic!(
274                    labels = vec![miette::LabeledSpan::at(
275                        (report.span.start(), report.span.len()),
276                        &report.message
277                    ),],
278                    severity = match report.severity {
279                        Severity::Info => miette::Severity::Advice,
280                        Severity::Warning => miette::Severity::Warning,
281                        Severity::Error => miette::Severity::Error,
282                    },
283                    "{}",
284                    report.message
285                )
286            });
287            match format {
288                OutputFormat::Human => {
289                    for diag in diagnostics {
290                        let report = miette::Report::new(diag).with_source_code(
291                            miette::NamedSource::new(path.display().to_string(), src.to_string()),
292                        );
293                        println!("{report:?}");
294                    }
295                }
296                OutputFormat::Json => {
297                    let output = serde_json::to_string(&diagnostics.collect_vec())?;
298                    println!("{}", output);
299                }
300            }
301
302            Ok(())
303        }
304    }
305}
306
307/// Write the JavaScript client file if it exists.
308///
309/// Returns `true` if the file exists, `false` otherwise.
310fn populate_js_client(endpoints: &tapi::endpoints::Endpoints<AppState>) -> bool {
311    let js_client_path = std::path::PathBuf::from("./crates/slang-ui/static/tapi.js");
312    // write JavaScript client if and only if the path already exists
313    if js_client_path.exists() {
314        // only write if the contents are different
315        let contents = endpoints.js_client();
316        let prev = std::fs::read_to_string(&js_client_path).unwrap_or_default();
317        if prev != contents {
318            let _ = std::fs::write(&js_client_path, contents);
319        }
320        true
321    } else {
322        false
323    }
324}
325
326async fn index_handler() -> impl IntoResponse {
327    static_handler("/index.html".parse::<Uri>().unwrap()).await
328}
329
330async fn static_handler(uri: Uri) -> impl IntoResponse {
331    StaticFile(uri.path().trim_start_matches('/').to_string())
332}
333
334pub struct StaticFile<T>(pub T);
335
336impl<T> IntoResponse for StaticFile<T>
337where
338    T: Into<String>,
339{
340    fn into_response(self) -> Response {
341        let path = self.0.into();
342
343        match Asset::get(path.as_str()) {
344            Some(content) => {
345                let mime = mime_guess::from_path(path).first_or_octet_stream();
346                ([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
347            }
348            None => (StatusCode::NOT_FOUND, "404 Not Found").into_response(),
349        }
350    }
351}
352
353#[derive(Clone)]
354struct AppState {
355    hook: Arc<dyn Hook + Send + Sync>,
356}
357
358impl std::fmt::Debug for AppState {
359    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360        f.debug_struct("AppState").field("hook", &"...").finish()
361    }
362}
363
364fn endpoints() -> tapi::endpoints::Endpoints<'static, AppState> {
365    tapi::endpoints::Endpoints::new([
366        &sample_files::endpoint as &dyn tapi::endpoints::Endpoint<AppState>,
367        &heartbeat::endpoint as _,
368        &analyze::endpoint as _,
369        &hover::endpoint as _,
370    ])
371}
372
373fn run_hook(
374    hook: &dyn Hook,
375    src: &str,
376) -> Result<Vec<Report>, (Vec<Report>, color_eyre::eyre::Error)> {
377    let span = tracing::span!(parent: tracing::Span::none(), tracing::Level::INFO, "analyze");
378    let _enter = span.enter();
379
380    let file = slang::parse_file(src);
381    let cx = Context::new(&file);
382    for err in &file.parse_errors {
383        cx.error(err.span(), format!("parse error: {}", err.msg()));
384    }
385    for err in &file.tc_errors {
386        cx.error(err.span(), err.msg());
387    }
388    match hook.analyze(&cx, &file) {
389        Ok(()) => Ok(cx.reports()),
390        Err(err) => Err((cx.reports(), err)),
391    }
392}
393
394#[derive(Debug, Serialize, Deserialize, tapi::Tapi)]
395enum Heartbeat {
396    Alive,
397}
398
399#[tapi::tapi(path = "/heartbeat", method = Get)]
400async fn heartbeat() -> tapi::endpoints::Sse<Heartbeat> {
401    let (tx, rx) = tokio::sync::mpsc::channel(1);
402
403    tokio::spawn(async move {
404        loop {
405            if tx.send(Ok(Heartbeat::Alive)).await.is_err() {
406                break;
407            }
408            tokio::time::sleep(std::time::Duration::from_secs(10)).await;
409        }
410    });
411
412    tapi::endpoints::Sse::new(tokio_stream::wrappers::ReceiverStream::new(rx))
413}
414
415#[derive(Debug, Default, Serialize, tapi::Tapi)]
416struct SampleFiles {
417    files: BTreeMap<String, String>,
418}
419
420#[tapi::tapi(path = "/sample-files", method = Get)]
421async fn sample_files() -> Json<SampleFiles> {
422    let Ok(files) = glob::glob("**/*.slang") else {
423        return Json(SampleFiles::default());
424    };
425    Json(SampleFiles {
426        files: files
427            .filter_map(|f| {
428                let f = f.ok()?;
429                let src = std::fs::read_to_string(&f).ok()?;
430                Some((f.display().to_string(), src))
431            })
432            .collect(),
433    })
434}
435
436#[derive(Debug, Serialize, Deserialize, tapi::Tapi)]
437pub struct AnalyzeParams {
438    file: String,
439}
440
441#[derive(Debug, Serialize, tapi::Tapi)]
442pub struct AnalyzeResult {
443    markers: Vec<monaco::MarkerData>,
444    analysis_errored: bool,
445    message: Option<(Option<String>, Color)>,
446}
447
448#[tapi::tapi(path = "/analyze", method = Post)]
449async fn analyze(state: State<AppState>, params: Json<AnalyzeParams>) -> Json<AnalyzeResult> {
450    let (reports, analysis_errored) = match run_hook(state.hook.as_ref(), &params.file) {
451        Ok(reports) => (reports, false),
452        Err((reports, err)) => {
453            eprintln!("{err:?}");
454            (reports, true)
455        }
456    };
457    Json(AnalyzeResult {
458        markers: reports
459            .iter()
460            .map(|r| monaco::MarkerData {
461                related_information: None,
462                tags: None,
463                severity: match r.severity {
464                    Severity::Info => monaco::MarkerSeverity::Info,
465                    Severity::Warning => monaco::MarkerSeverity::Warning,
466                    Severity::Error => monaco::MarkerSeverity::Error,
467                },
468                message: r.message.clone(),
469                span: monaco::MonacoSpan::from_source_span(&params.file, r.span),
470            })
471            .collect(),
472        analysis_errored,
473        message: None,
474    })
475}
476
477#[derive(Debug, Deserialize, tapi::Tapi)]
478pub struct HoverParams {
479    pub file: String,
480    pub pos: monaco::MonacoPosition,
481}
482#[derive(Debug, Serialize, tapi::Tapi)]
483pub struct HoverResult {
484    pub span: monaco::MonacoSpan,
485    pub contents: Vec<String>,
486}
487
488#[tapi::tapi(path = "/hover", method = Post)]
489async fn hover(state: State<AppState>, params: Json<HoverParams>) -> Json<Option<HoverResult>> {
490    let file = slang::parse_file(&params.file);
491    let cx = Context::new(&file);
492    let result = match state.hook.hover(
493        &cx,
494        &file,
495        slang::Position {
496            column: params.pos.column,
497            line: params.pos.line_number,
498        },
499    ) {
500        Some(res) => Some(HoverResult {
501            span: monaco::MonacoSpan::from_source_span(&params.file, res.span),
502            contents: res.contents,
503        }),
504        None => None,
505    };
506
507    Json(result)
508}