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