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 #[clap(short, long, default_value_t = false)]
251 open: bool,
252 #[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
366fn 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 if js_client_path.exists() {
373 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(), ¶ms.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(¶ms.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(¶ms.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(¶ms.file, res.span),
587 contents: res.contents,
588 }),
589 None => None,
590 };
591
592 Json(result)
593}