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 #[clap(short, long, default_value_t = false)]
232 open: bool,
233 #[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
347fn 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 if js_client_path.exists() {
354 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(), ¶ms.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(¶ms.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(¶ms.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(¶ms.file, res.span),
542 contents: res.contents,
543 }),
544 None => None,
545 };
546
547 Json(result)
548}