Skip to main content

slang_ui/
lib.rs

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