diff --git a/Cargo.lock b/Cargo.lock index 9179a464..28fcd9ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3993,6 +3993,7 @@ dependencies = [ "codespan-reporting", "comemo", "crossbeam-channel", + "dapts", "dhat", "dirs", "env_logger", @@ -4095,6 +4096,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "tinymist-dap" +version = "0.13.8" +dependencies = [ + "base64", + "comemo", + "insta", + "parking_lot", + "serde", + "serde_json", + "tinymist-analysis", + "tinymist-std", + "tinymist-world", + "typst", + "typst-library", +] + [[package]] name = "tinymist-debug" version = "0.13.8" diff --git a/crates/tinymist-dap/Cargo.toml b/crates/tinymist-dap/Cargo.toml new file mode 100644 index 00000000..12c3f228 --- /dev/null +++ b/crates/tinymist-dap/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "tinymist-dap" +description = "Fast debugger implementation for typst." +categories = ["compilers"] +keywords = ["api", "debugger", "typst"] +authors.workspace = true +version.workspace = true +license.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +typst-library.workspace = true +typst.workspace = true +tinymist-std.workspace = true +tinymist-analysis.workspace = true +tinymist-world = { workspace = true, features = ["system"] } +parking_lot.workspace = true +comemo.workspace = true +serde.workspace = true +serde_json.workspace = true +base64.workspace = true + +[dev-dependencies] +insta.workspace = true + +[lints] +workspace = true diff --git a/crates/tinymist-dap/src/lib.rs b/crates/tinymist-dap/src/lib.rs new file mode 100644 index 00000000..515529c7 --- /dev/null +++ b/crates/tinymist-dap/src/lib.rs @@ -0,0 +1,15 @@ +//! Fast debugger implementation for typst. + +// this._runtime = new MockRuntime(fileAccessor); + +// this._runtime.on("breakpointValidated", (bp: IRuntimeBreakpoint) => { +// this.sendEvent( +// new BreakpointEvent("changed", { +// verified: bp.verified, +// id: bp.id, +// } as DebugProtocol.Breakpoint), +// ); +// }); +// this._runtime.on("end", () => { +// this.sendEvent(new TerminatedEvent()); +// }); diff --git a/crates/tinymist/Cargo.toml b/crates/tinymist/Cargo.toml index 89d4dc46..8376f655 100644 --- a/crates/tinymist/Cargo.toml +++ b/crates/tinymist/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tinymist" -description = "Fast lsp implementation for typst." +description = "An integrated language service for Typst." categories = ["compilers", "command-line-utilities"] keywords = ["cli", "lsp", "language", "typst"] authors.workspace = true @@ -85,6 +85,8 @@ unicode-script.workspace = true walkdir.workspace = true tinymist-l10n.workspace = true +dapts.workspace = true + [features] default = [ "cli", @@ -95,6 +97,7 @@ default = [ "preview", "embed-fonts", "no-content-hint", + "dap", ] cli = ["sync-ls/clap", "clap/wrap_help"] @@ -131,6 +134,8 @@ preview = [ "hyper-tungstenite", ] +dap = [] + # l10n = ["tinymist-assets/l10n"] l10n = [] diff --git a/crates/tinymist/src/args.rs b/crates/tinymist/src/args.rs index 50c33027..7e17afc0 100644 --- a/crates/tinymist/src/args.rs +++ b/crates/tinymist/src/args.rs @@ -24,6 +24,8 @@ pub enum Commands { Completion(ShellCompletionArgs), /// Runs language server Lsp(LspArgs), + /// Runs debug adapter + Dap(DapArgs), /// Runs language server for tracing some typst program. #[clap(hide(true))] TraceLsp(TraceLspArgs), @@ -158,6 +160,8 @@ pub struct LspArgs { pub font: CompileFontArgs, } +pub type DapArgs = LspArgs; + #[derive(Debug, Clone, clap::Subcommand)] #[clap(rename_all = "camelCase")] pub enum QueryCommands { diff --git a/crates/tinymist/src/config.rs b/crates/tinymist/src/config.rs index fda4d382..d0648969 100644 --- a/crates/tinymist/src/config.rs +++ b/crates/tinymist/src/config.rs @@ -63,6 +63,8 @@ pub struct Config { pub project_resolution: ProjectResolutionKind, /// Constant configuration for the server. pub const_config: ConstConfig, + /// Constant DAP configuration for the server. + pub const_dap_config: ConstDapConfig, /// The compile configurations pub compile: CompileConfig, /// Dynamic configuration for semantic tokens. @@ -93,6 +95,7 @@ impl Config { ) -> Self { let mut config = Self { const_config, + const_dap_config: ConstDapConfig::default(), compile: CompileConfig { entry_resolver: EntryResolver { roots, @@ -151,6 +154,35 @@ impl Config { (config, err) } + /// Creates a new configuration from the dap initialization parameters. + /// + /// The function has side effects: + /// - Getting environment variables. + /// - Setting the locale. + pub fn extract_dap_params( + params: dapts::InitializeRequestArguments, + font_opts: CompileFontArgs, + ) -> (Self, Option) { + // todo: lines_start_at1, columns_start_at1, path_format + + // This is reliable in DAP context. + let cwd = std::env::current_dir() + .expect("failed to get current directory") + .into(); + + // Initialize configurations + let roots = vec![cwd]; + let mut config = Config::new(ConstConfig::from(¶ms), roots, font_opts); + config.const_dap_config = ConstDapConfig::from(¶ms); + + // Sets locale as soon as possible + if let Some(locale) = config.const_config.locale.as_ref() { + tinymist_l10n::set_locale(locale); + } + + (config, None) + } + /// Gets items for serialization. pub fn get_items() -> Vec { let sections = CONFIG_ITEMS @@ -383,6 +415,49 @@ impl From<&InitializeParams> for ConstConfig { } } +impl From<&dapts::InitializeRequestArguments> for ConstConfig { + fn from(params: &dapts::InitializeRequestArguments) -> Self { + let locale = params.locale.as_deref(); + + Self { + locale: locale.map(ToOwned::to_owned), + ..Default::default() + } + } +} + +/// Determines in what format paths are specified. The default is `path`, which +/// is the native format. +pub type DapPathFormat = dapts::InitializeRequestArgumentsPathFormat; + +/// Configuration set at initialization that won't change within a single DAP +/// session. +#[derive(Debug, Clone)] +pub struct ConstDapConfig { + /// The format of paths. + pub path_format: DapPathFormat, + /// Whether lines start at 1. + pub lines_start_at1: bool, + /// Whether columns start at 1. + pub columns_start_at1: bool, +} + +impl Default for ConstDapConfig { + fn default() -> Self { + Self::from(&dapts::InitializeRequestArguments::default()) + } +} + +impl From<&dapts::InitializeRequestArguments> for ConstDapConfig { + fn from(params: &dapts::InitializeRequestArguments) -> Self { + Self { + path_format: params.path_format.clone().unwrap_or(DapPathFormat::Path), + lines_start_at1: params.lines_start_at1.unwrap_or(true), + columns_start_at1: params.columns_start_at1.unwrap_or(true), + } + } +} + /// The user configuration read from the editor. #[derive(Debug, Default, Clone)] pub struct CompileConfig { @@ -1056,6 +1131,15 @@ mod tests { assert!(err.is_none()); } + #[test] + fn test_default_dap_config_initialize() { + let (_conf, err) = Config::extract_dap_params( + dapts::InitializeRequestArguments::default(), + CompileFontArgs::default(), + ); + assert!(err.is_none()); + } + #[test] fn test_config_package_path_from_env() { let pkg_path = Path::new(if cfg!(windows) { "C:\\pkgs" } else { "/pkgs" }); diff --git a/crates/tinymist/src/dap.rs b/crates/tinymist/src/dap.rs new file mode 100644 index 00000000..b2ee30fd --- /dev/null +++ b/crates/tinymist/src/dap.rs @@ -0,0 +1,98 @@ +#![allow(unused)] + +mod event; +mod init; +mod request; + +pub use init::*; + +use reflexo_typst::vfs::PathResolution; +use serde::{Deserialize, Serialize}; +use sync_ls::{invalid_request, LspResult}; +use tinymist_query::PositionEncoding; +// use sync_lsp::RequestId; +use typst::syntax::{FileId, Source}; + +use crate::project::LspCompileSnapshot; +use crate::{ConstDapConfig, ServerState}; + +#[derive(Default)] +pub(crate) struct DebugState { + pub(crate) session: Option, +} + +impl DebugState { + pub(crate) fn session(&self) -> LspResult<&DebugSession> { + self.session + .as_ref() + .ok_or_else(|| invalid_request("No active debug session")) + } +} + +pub(crate) struct DebugSession { + config: ConstDapConfig, + + snapshot: LspCompileSnapshot, + /// A faked thread id. We don't support multiple threads, so we can use a + /// hardcoded ID for the default thread. + thread_id: u64, + /// Whether the debugger should stop on entry. + stop_on_entry: bool, + + /// The current source file. + source: Source, + /// The current position. + position: usize, +} +// private _variableHandles = new Handles<"locals" | "globals" | +// RuntimeVariable>(); + +// private _valuesInHex = false; +// private _useInvalidatedEvent = false; + +const DAP_POS_ENCODING: PositionEncoding = PositionEncoding::Utf16; + +impl DebugSession { + pub fn to_dap_source(&self, id: FileId) -> dapts::Source { + use dapts::Source; + Source { + path: match self.snapshot.world.path_for_id(id).ok() { + Some(PathResolution::Resolved(path)) => Some(path.display().to_string()), + None | Some(PathResolution::Rootless(..)) => None, + }, + ..Source::default() + } + } + + pub fn to_dap_position(&self, pos: usize, source: &Source) -> DapPosition { + let mut lsp_pos = tinymist_query::to_lsp_position(pos, DAP_POS_ENCODING, source); + + if self.config.lines_start_at1 { + lsp_pos.line += 1; + } + if self.config.columns_start_at1 { + lsp_pos.character += 1; + } + + DapPosition { + line: lsp_pos.line as u64, + character: lsp_pos.character as u64, + } + } +} + +/// Position in a text document expressed as line and character offset. +/// A position is between two characters like an 'insert' cursor in a editor. +/// +/// Whether or not the line and column are 0 or 1-based is negotiated between +/// the client and server. +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Copy, Clone, Default, Deserialize, Serialize)] +pub struct DapPosition { + /// Line position in a document. + pub line: u64, + /// Character offset on a line in a document. + /// + /// If the character value is greater than the line length it defaults back + /// to the line length. + pub character: u64, +} diff --git a/crates/tinymist/src/dap/event.rs b/crates/tinymist/src/dap/event.rs new file mode 100644 index 00000000..c307f1c3 --- /dev/null +++ b/crates/tinymist/src/dap/event.rs @@ -0,0 +1,3 @@ +use super::*; + +impl ServerState {} diff --git a/crates/tinymist/src/dap/init.rs b/crates/tinymist/src/dap/init.rs new file mode 100644 index 00000000..519a5e40 --- /dev/null +++ b/crates/tinymist/src/dap/init.rs @@ -0,0 +1,147 @@ +use dapts::InitializeRequestArguments; +use sync_ls::*; +use tinymist_project::CompileFontArgs; + +use crate::{Config, ServerState}; + +/// The regular initializer. +pub struct RegularInit { + /// The connection to the client. + pub client: TypedLspClient, + /// The font options for the compiler. + pub font_opts: CompileFontArgs, +} + +impl Initializer for RegularInit { + type I = InitializeRequestArguments; + type S = ServerState; + fn initialize( + self, + params: InitializeRequestArguments, + ) -> (ServerState, AnySchedulableResponse) { + let (config, err) = Config::extract_dap_params(params, self.font_opts); + + // if (args.supportsInvalidatedEvent) { + // this._useInvalidatedEvent = true; + // } + + let super_init = SuperInit { + client: self.client, + config, + err, + }; + + super_init.initialize(()) + } +} + +/// The super DAP initializer. +pub struct SuperInit { + /// Using the connection to the client. + pub client: TypedLspClient, + /// The configuration for the server. + pub config: Config, + /// Whether an error occurred before super initialization. + pub err: Option, +} + +impl Initializer for SuperInit { + type I = (); + type S = ServerState; + /// The 'initialize' request is the first request called by the frontend + /// to interrogate the features the debug adapter provides. + fn initialize(self, _params: ()) -> (ServerState, AnySchedulableResponse) { + let SuperInit { + client, + config, + err, + } = self; + // Bootstrap server + let service = ServerState::main(client, config, err.is_none()); + + if let Some(err) = err { + return (service, Err(err)); + } + + // build and return the capabilities of this debug adapter: + let res = dapts::Capabilities { + supports_configuration_done_request: Some(true), + + // make client use 'evaluate' when hovering over source + supports_evaluate_for_hovers: Some(true), + // Don't show a 'step back' button + supports_step_back: Some(false), + supports_data_breakpoints: Some(true), + // make client support completion in REPL + supports_completions_request: Some(true), + completion_trigger_characters: Some(vec!['.'.into(), '['.into()]), + + supports_cancel_request: Some(false), + + // make client send the breakpointLocations request + supports_breakpoint_locations_request: Some(true), + // make client provide "Step in Target" functionality + supports_step_in_targets_request: Some(true), + + // the adapter defines two exceptions filters, one with support for + // conditions. + supports_exception_filter_options: Some(false), + supports_exception_info_request: Some(false), + exception_breakpoint_filters: Some(vec![ + dapts::ExceptionBreakpointsFilter { + filter: "layoutIterationException".into(), + label: "Layout Iteration Exception".into(), + description: Some("Break on each layout iteration.".into()), + default: Some(false), + supports_condition: Some(true), + condition_description: Some( + "Enter a typst expression to stop on specific layout iterator. + e.g. `iterate-step == 3 and sys.inputs.target == \"html\"`" + .into(), + ), + }, + dapts::ExceptionBreakpointsFilter { + filter: "otherExceptions".into(), + label: "Other Exceptions".into(), + description: Some("This is a other exception".into()), + default: Some(true), + supports_condition: Some(false), + condition_description: None, + }, + ]), + + supports_set_variable: Some(false), + supports_set_expression: Some(false), + + // make client send disassemble request + supports_disassemble_request: Some(false), + + supports_stepping_granularity: Some(true), + supports_instruction_breakpoints: Some(false), + + // make client able to read and write variable memory + supports_read_memory_request: Some(false), + supports_write_memory_request: Some(false), + + support_suspend_debuggee: Some(true), + support_terminate_debuggee: Some(true), + // supports_terminate_request: Some(true), + supports_function_breakpoints: Some(true), + supports_delayed_stack_trace_loading: Some(true), + + ..Default::default() + }; + + let res = serde_json::to_value(res).map_err(|e| invalid_params(e.to_string())); + + // since this debug adapter can accept configuration requests like + // 'setBreakpoint' at any time, we request them early by sending an + // 'initializeRequest' to the frontend. The frontend will end the + // configuration sequence by calling 'configurationDone' request. + service + .client + .send_dap_event::(None); + + (service, just_result(res)) + } +} diff --git a/crates/tinymist/src/dap/request.rs b/crates/tinymist/src/dap/request.rs new file mode 100644 index 00000000..de573784 --- /dev/null +++ b/crates/tinymist/src/dap/request.rs @@ -0,0 +1,243 @@ +use std::path::{Path, PathBuf}; + +use comemo::Track; +use dapts::{CompletionItem, ProcessEventStartMethod, StoppedEventReason, ThreadEventReason}; +use reflexo::ImmutPath; +use reflexo_typst::{EntryReader, TaskInputs}; +use serde::Deserialize; +use sync_ls::{internal_error, invalid_params, invalid_request, just_ok, SchedulableResponse}; +use tinymist_std::error::prelude::*; +use typst::{ + foundations::Repr, + routines::EvalMode, + syntax::{LinkedNode, Span}, + World, +}; +use typst_shim::syntax::LinkedNodeExt; + +use super::*; + +impl ServerState { + /// Called at the end of the configuration sequence. + /// Indicates that all breakpoints etc. have been sent to the DA and that + /// the 'launch' can start. + pub(crate) fn configuration_done( + &mut self, + _args: dapts::ConfigurationDoneArguments, + ) -> SchedulableResponse<()> { + just_ok(()) + } + + /// Should stop the debug session. + pub(crate) fn disconnect( + &mut self, + _args: dapts::DisconnectArguments, + ) -> SchedulableResponse<()> { + let _ = self.debug.session.take(); + + just_ok(()) + } + + pub(crate) fn terminate_debug( + &mut self, + _args: dapts::TerminateArguments, + ) -> SchedulableResponse<()> { + let _ = self.debug.session.take(); + + self.client + .send_dap_event::(dapts::TerminatedEvent { restart: None }); + + just_ok(()) + } + + pub(crate) fn terminate_debug_thread( + &mut self, + args: dapts::TerminateThreadsArguments, + ) -> SchedulableResponse<()> { + if args.thread_ids.as_ref().is_none_or(|id| id.is_empty()) { + return just_ok(()); + } + let terminate_thread_ok = args.thread_ids.into_iter().flatten().all(|id| id == 1); + if terminate_thread_ok { + let _ = self.debug.session.take(); + } + + just_ok(()) + } + + // cancelRequest + + pub(crate) fn attach_debug( + &mut self, + args: dapts::AttachRequestArguments, + ) -> SchedulableResponse<()> { + self.launch_debug_( + dapts::LaunchRequestArguments { raw: args.raw }, + ProcessEventStartMethod::Attach, + ) + } + + pub(crate) fn launch_debug( + &mut self, + args: dapts::LaunchRequestArguments, + ) -> SchedulableResponse<()> { + self.launch_debug_(args, ProcessEventStartMethod::Launch) + } + + pub(crate) fn launch_debug_( + &mut self, + args: dapts::LaunchRequestArguments, + method: ProcessEventStartMethod, + ) -> SchedulableResponse<()> { + // wait 1 second until configuration has finished (and configurationDoneRequest + // has been called) await this._configurationDone.wait(1000); + + // start the program in the runtime + let args = serde_json::from_value::(args.raw).unwrap(); + + let program: ImmutPath = Path::new(&args.program).into(); + let root = Path::new(&args.root).into(); + let input = self.resolve_task(program.clone()); + let entry = self + .entry_resolver() + .resolve_with_root(Some(root), Some(program)); + + // todo: respect lock file + let input = TaskInputs { + entry: Some(entry), + inputs: input.inputs, + }; + + let snapshot = self.project.snapshot().unwrap().task(input); + let world = &snapshot.world; + + let main = world + .main_id() + .ok_or_else(|| internal_error("No main file found"))?; + let main_source = world.source(main).map_err(invalid_request)?; + let main_eof = main_source.text().len(); + let source = main_source.clone(); + + self.debug.session = Some(DebugSession { + config: self.config.const_dap_config.clone(), + snapshot, + stop_on_entry: args.stop_on_entry.unwrap_or_default(), + thread_id: 1, + // Since we haven't implemented breakpoints, we can only stop intermediately and + // response completions in repl console. + source, + position: main_eof, + }); + + self.client + .send_dap_event::(dapts::ProcessEvent { + name: "typst".into(), + start_method: Some(method), + ..dapts::ProcessEvent::default() + }); + + self.client + .send_dap_event::(dapts::ThreadEvent { + reason: ThreadEventReason::Started, + thread_id: self.debug.session()?.thread_id, + }); + + // Since we haven't implemented breakpoints, we can only stop intermediately and + // response completions in repl console. + let _ = self.debug.session()?.stop_on_entry; + self.client + .send_dap_event::(dapts::StoppedEvent { + all_threads_stopped: Some(true), + reason: StoppedEventReason::Pause, + description: Some("Paused at the end of the document".into()), + thread_id: Some(self.debug.session()?.thread_id), + hit_breakpoint_ids: None, + preserve_focus_hint: Some(false), + text: None, + }); + + just_ok(()) + } + + // customRequest +} + +/// This interface describes the mock-debug specific launch attributes +/// (which are not part of the Debug Adapter Protocol). +/// The schema for these attributes lives in the package.json of the mock-debug +/// extension. The interface should always match this schema. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LaunchDebugArguments { + /// An absolute path to the "program" to debug. + program: String, + /// The root directory of the program (used to resolve absolute paths). + root: String, + /// Automatically stop target after launch. If not specified, target does + /// not stop. + stop_on_entry: Option, +} + +impl ServerState { + pub(crate) fn debug_threads( + &mut self, + _args: (), + ) -> SchedulableResponse { + just_ok(dapts::ThreadsResponse { + threads: vec![dapts::Thread { + id: 1, + name: "thread 1".into(), + }], + }) + } +} + +impl ServerState { + pub(crate) fn evaluate_repl( + &mut self, + args: dapts::EvaluateArguments, + ) -> SchedulableResponse { + let session = self.debug.session()?; + let world = &session.snapshot.world; + let library = &world.library; + + let root = session.source.root(); + let span = LinkedNode::new(root) + .leaf_at_compat(session.position) + .map(|node| node.span()) + .unwrap_or_else(Span::detached); + + let source = typst_shim::eval::eval_compat(&world, &session.source) + .map_err(|e| invalid_params(format!("{e:?}")))?; + + let val = typst_shim::eval::eval_string( + &typst::ROUTINES, + (world as &dyn World).track(), + &args.expression, + span, + EvalMode::Code, + source.scope().clone(), + ) + .map_err(|e| invalid_params(format!("{e:?}")))?; + + just_ok(dapts::EvaluateResponse { + result: format!("{}", val.repr()), + ty: Some(format!("{}", val.ty().repr())), + ..dapts::EvaluateResponse::default() + }) + } + + pub(crate) fn complete_repl( + &mut self, + args: dapts::CompletionsArguments, + ) -> SchedulableResponse { + let _ = args; + let session = self + .debug + .session + .as_ref() + .ok_or_else(|| internal_error("No debug session found"))?; + + just_ok(dapts::CompletionsResponse { targets: vec![] }) + } +} diff --git a/crates/tinymist/src/lib.rs b/crates/tinymist/src/lib.rs index e6a92c15..d13bb95f 100644 --- a/crates/tinymist/src/lib.rs +++ b/crates/tinymist/src/lib.rs @@ -21,6 +21,7 @@ mod actor; mod cmd; pub(crate) mod config; +pub(crate) mod dap; pub(crate) mod input; pub(crate) mod lsp; pub mod project; @@ -33,6 +34,8 @@ pub mod tool; mod utils; pub use config::*; +pub use dap::RegularInit as DapRegularInit; +pub use dap::SuperInit as DapSuperInit; pub use lsp::init::*; pub use server::*; pub use sync_ls::LspClient; diff --git a/crates/tinymist/src/main.rs b/crates/tinymist/src/main.rs index 3c4afc4a..786b98ee 100644 --- a/crates/tinymist/src/main.rs +++ b/crates/tinymist/src/main.rs @@ -14,12 +14,17 @@ use once_cell::sync::Lazy; use reflexo::ImmutPath; use reflexo_typst::package::PackageSpec; use sync_ls::transport::{with_stdio_transport, MirrorArgs}; -use sync_ls::{internal_error, LspBuilder, LspClientRoot, LspMessage, LspResult, RequestId}; +use sync_ls::{ + internal_error, DapBuilder, DapMessage, LspBuilder, LspClientRoot, LspMessage, LspResult, + RequestId, +}; use tinymist::tool::project::{ compile_main, coverage_main, generate_script_main, project_main, task_main, }; use tinymist::world::TaskInputs; -use tinymist::{CompileConfig, Config, RegularInit, ServerState, SuperInit, UserActionTask}; +use tinymist::{ + CompileConfig, Config, DapRegularInit, RegularInit, ServerState, SuperInit, UserActionTask, +}; use tinymist_core::LONG_VERSION; use tinymist_project::EntryResolver; use tinymist_query::package::PackageInfo; @@ -94,6 +99,7 @@ fn main() -> Result<()> { Commands::GenerateScript(args) => generate_script_main(args), Commands::Query(query_cmds) => query_main(query_cmds), Commands::Lsp(args) => lsp_main(args), + Commands::Dap(args) => dap_main(args), Commands::TraceLsp(args) => trace_lsp_main(args), #[cfg(feature = "preview")] Commands::Preview(args) => { @@ -148,6 +154,33 @@ pub fn lsp_main(args: LspArgs) -> Result<()> { Ok(()) } +/// The main entry point for the language server. +pub fn dap_main(args: DapArgs) -> Result<()> { + let pairs = LONG_VERSION.trim().split('\n'); + let pairs = pairs + .map(|e| e.splitn(2, ":").map(|e| e.trim()).collect::>()) + .collect::>(); + log::info!("tinymist version information: {pairs:?}"); + log::info!("starting debug adaptor: {args:?}"); + + let is_replay = !args.mirror.replay.is_empty(); + with_stdio_transport::(args.mirror.clone(), |conn| { + let client = LspClientRoot::new(RUNTIMES.tokio_runtime.handle().clone(), conn.sender); + ServerState::install_dap(DapBuilder::new( + DapRegularInit { + client: client.weak().to_typed(), + font_opts: args.font, + }, + client.weak(), + )) + .build() + .start(conn.receiver, is_replay) + })?; + + log::info!("language server did shut down"); + Ok(()) +} + /// The main entry point for the compiler. pub fn trace_lsp_main(args: TraceLspArgs) -> Result<()> { let inputs = args.compile.resolve_inputs(); diff --git a/crates/tinymist/src/server.rs b/crates/tinymist/src/server.rs index f203da78..9a047a5e 100644 --- a/crates/tinymist/src/server.rs +++ b/crates/tinymist/src/server.rs @@ -49,6 +49,8 @@ pub struct ServerState { /// The preview state. #[cfg(feature = "preview")] pub preview: tool::preview::PreviewState, + #[cfg(feature = "dap")] + pub(crate) debug: crate::dap::DebugState, /// The formatter tasks running in backend, which will be scheduled by async /// runtime. pub formatter: FormatTask, @@ -113,6 +115,8 @@ impl ServerState { memory_changes: HashMap::new(), #[cfg(feature = "preview")] preview: tool::preview::PreviewState::new(watchers, client.cast(|s| &mut s.preview)), + #[cfg(feature = "dap")] + debug: crate::dap::DebugState::default(), ever_focusing_by_activities: false, ever_manual_focusing: false, sema_tokens_registered: false, @@ -289,6 +293,25 @@ impl ServerState { provider } + /// Installs DAP handlers to the language server. + pub fn install_dap + 'static>( + provider: DapBuilder, + ) -> DapBuilder { + use dapts::request; + + // todo: .on_sync_mut::(handlers::handle_cancel)? + provider + .with_request::(Self::configuration_done) + .with_request::(Self::disconnect) + .with_request::(Self::terminate_debug) + .with_request::(Self::terminate_debug_thread) + .with_request::(Self::attach_debug) + .with_request::(Self::launch_debug) + .with_request::(Self::evaluate_repl) + .with_request::(Self::complete_repl) + .with_request::(Self::debug_threads) + } + /// Handles the project interrupts. fn compile_interrupt>( mut state: ServiceState, diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 90b9bd76..76cfec07 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -9,6 +9,7 @@ ], "categories": [ "Programming Languages", + "Debuggers", "Formatters" ], "repository": { @@ -973,6 +974,87 @@ } } ], + "breakpoints": [ + { + "language": "typst" + } + ], + "debuggers": [ + { + "type": "myriaddreamin.typst-debugger", + "languages": [ + "typst" + ], + "label": "Typst Debug", + "program": "./out/tinymist", + "windows": { + "program": "./out/tinymist.exe" + }, + "configurationAttributes": { + "launch": { + "required": [ + "program" + ], + "properties": { + "program": { + "type": "string", + "description": "Absolute path to a text file.", + "default": "${workspaceFolder}/${command:AskForProgramName}" + }, + "stopOnEntry": { + "type": "boolean", + "description": "Automatically stop after launch.", + "default": true + }, + "trace": { + "type": "boolean", + "description": "Enable logging of the Debug Adapter Protocol.", + "default": true + }, + "compileError": { + "type": "string", + "description": "Simulates a compile error in 'launch' request.", + "enum": [ + "default", + "show", + "hide" + ], + "enumDescriptions": [ + "default: show fake compile error to user", + "show fake compile error to user", + "do not show fake compile error to user" + ] + } + } + } + }, + "initialConfigurations": [ + { + "type": "myriaddreamin.typst-debugger", + "request": "launch", + "name": "Ask for file name", + "program": "${workspaceFolder}/${command:AskForProgramName}", + "stopOnEntry": true + } + ], + "configurationSnippets": [ + { + "label": "Typst Debug: Launch", + "description": "A new configuration for 'debugging' a user selected typst file.", + "body": { + "type": "myriaddreamin.typst-debugger", + "request": "launch", + "name": "Ask for file name", + "program": "^\"\\${workspaceFolder}/\\${command:AskForProgramName}\"", + "stopOnEntry": true + } + } + ], + "variables": { + "AskForProgramName": "tinymist.debuggerGetProgramName" + } + } + ], "commands": [ { "command": "tinymist.exportCurrentPdf", @@ -1119,6 +1201,13 @@ "title": "%extension.tinymist.command.typst-preview.noteOutline%", "description": "...", "icon": "$(extensions-info-message)" + }, + { + "command": "tinymist.debugCurrentFile", + "title": "Execute Current File for Debugging", + "category": "Typst", + "enablement": "!inDebugMode", + "icon": "$(debug-alt)" } ], "keybindings": [ @@ -1144,6 +1233,10 @@ "command": "tinymist.clearCache", "when": "editorLangId == typst" }, + { + "command": "tinymist.debugCurrentFile", + "when": "resourceLangId == typst" + }, { "command": "tinymist.restartServer", "when": "ext.tinymistActivated" @@ -1161,6 +1254,13 @@ "group": "navigation" } ], + "editor/title/run": [ + { + "command": "tinymist.debugCurrentFile", + "when": "resourceLangId == typst", + "group": "navigation@2" + } + ], "editor/context": [ { "command": "tinymist.copyAnsiHighlight", diff --git a/editors/vscode/src/context.ts b/editors/vscode/src/context.ts index f77cb437..1c0f669c 100644 --- a/editors/vscode/src/context.ts +++ b/editors/vscode/src/context.ts @@ -78,6 +78,7 @@ export class IContext { } // todo: provide it correctly. + tinymistExecutable?: string; tinymistExec?: ICommand>; showErrorMessage(message: string) { diff --git a/editors/vscode/src/dap.ts b/editors/vscode/src/dap.ts new file mode 100644 index 00000000..14280f47 --- /dev/null +++ b/editors/vscode/src/dap.ts @@ -0,0 +1,60 @@ +import * as vscode from "vscode"; +import { ProviderResult } from "vscode"; +import { IContext } from "./context"; + +export class DebugAdapterExecutableFactory implements vscode.DebugAdapterDescriptorFactory { + // static outputChannel = vscode.window.createOutputChannel("Tinymist Debugging", "log"); + + constructor(private readonly context: IContext) {} + + // The following use of a DebugAdapter factory shows how to control what debug adapter executable is used. + // Since the code implements the default behavior, it is absolutely not necessary and we show it here only for educational purpose. + createDebugAdapterDescriptor( + session: vscode.DebugSession, + executable: vscode.DebugAdapterExecutable | undefined, + ): ProviderResult { + const isProdMode = this.context.context.extensionMode === vscode.ExtensionMode.Production; + + const hasMirrorFlag = () => { + return executable?.args?.some((arg) => arg.startsWith("--mirror=") || arg === "--mirror"); + }; + + /// The `--mirror` flag is only used in development/test mode for testing + const mirrorFlag = isProdMode ? [] : hasMirrorFlag() ? [] : ["--mirror", "tinymist-dap.log"]; + /// Set the `RUST_BACKTRACE` environment variable to `full` to print full backtrace on error. This is useless in + /// production mode because we don't put the debug information in the binary. + /// + /// Note: Developers can still download the debug information from the GitHub Releases and enable the backtrace + /// manually by themselves. + const RUST_BACKTRACE = isProdMode ? "1" : "full"; + + const args = executable?.args?.length + ? [...executable.args, ...mirrorFlag] + : ["dap", ...mirrorFlag]; + + const command = executable?.command || this.context.tinymistExecutable; + + console.log("dap executable", executable, "=>", command, args); + + if (!command) { + vscode.window.showErrorMessage("Cannot find tinymist executable to debug"); + return; + } + + // todo: resolve the cwd according to the program being debugged + const cwd = + executable?.options?.cwd || + session.workspaceFolder?.uri.fsPath || + (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0 + ? vscode.workspace.workspaceFolders[0].uri.fsPath + : undefined); + + return new vscode.DebugAdapterExecutable(command, args, { + cwd, + env: { + ...(executable?.options?.env || {}), + RUST_BACKTRACE, + }, + }); + } +} diff --git a/editors/vscode/src/extension.shared.ts b/editors/vscode/src/extension.shared.ts index fbf09caa..cc79df10 100644 --- a/editors/vscode/src/extension.shared.ts +++ b/editors/vscode/src/extension.shared.ts @@ -112,6 +112,7 @@ export async function tinymistActivate( tinymist.initClient(config); } + contextExt.tinymistExecutable = executable; contextExt.tinymistExec = makeExecCommand(contextExt, executable); } // Register Shared commands diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 5039ff94..eab6a79a 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -28,6 +28,7 @@ import { packageFeatureActivate } from "./features/package"; import { toolFeatureActivate } from "./features/tool"; import { copyAndPasteActivate, dragAndDropActivate } from "./features/drop-paste"; import { testingFeatureActivate } from "./features/testing"; +import { testingDebugActivate } from "./features/testing/debug"; import { FeatureEntry, tinymistActivate, tinymistDeactivate } from "./extension.shared"; import { LanguageClient } from "vscode-languageclient/node"; import { IContext } from "./context"; @@ -42,6 +43,7 @@ const systemActivateTable = (): FeatureEntry[] => [ [extensionState.features.copyAndPaste, copyAndPasteActivate], [extensionState.features.task, taskActivate], [extensionState.features.testing, testingFeatureActivate], + [extensionState.features.testingDebug, testingDebugActivate], [extensionState.features.devKit, devKitFeatureActivate], [extensionState.features.preview, previewActivateInTinymist, previewDeactivate], [extensionState.features.language, languageActivate], diff --git a/editors/vscode/src/extension.web.ts b/editors/vscode/src/extension.web.ts index cc011017..f7178ba6 100644 --- a/editors/vscode/src/extension.web.ts +++ b/editors/vscode/src/extension.web.ts @@ -19,6 +19,7 @@ export async function activate(context: ExtensionContext): Promise { copyAndPaste: false, onEnter: false, testing: false, + testingDebug: false, preview: false, language: false, renderDocs: false, diff --git a/editors/vscode/src/features/testing/debug.ts b/editors/vscode/src/features/testing/debug.ts new file mode 100644 index 00000000..7c782089 --- /dev/null +++ b/editors/vscode/src/features/testing/debug.ts @@ -0,0 +1,119 @@ +import * as vscode from "vscode"; +import { WorkspaceFolder, DebugConfiguration, ProviderResult, CancellationToken } from "vscode"; +import { IContext } from "../../context"; +import { DebugAdapterExecutableFactory } from "../../dap"; + +export const TYPST_DEBUGGER_TYPE = "myriaddreamin.typst-debugger"; + +export function testingDebugActivate(context: IContext) { + const factory: vscode.DebugAdapterDescriptorFactory = new DebugAdapterExecutableFactory(context); + + context.subscriptions.push( + vscode.commands.registerCommand("tinymist.debugCurrentFile", (resource: vscode.Uri) => { + let targetResource = resource; + if (!targetResource && vscode.window.activeTextEditor) { + targetResource = vscode.window.activeTextEditor.document.uri; + } + if (targetResource) { + vscode.debug.startDebugging(undefined, { + type: TYPST_DEBUGGER_TYPE, + name: "Debug File", + request: "launch", + program: targetResource.fsPath, + root: vscode.workspace.getWorkspaceFolder(targetResource)?.uri.fsPath, + stopOnEntry: true, + }); + } + }), + vscode.commands.registerCommand("tinymist.getcursorStyleChains", () => { + vscode.debug.activeDebugSession?.customRequest("getcursorStyleChains"); + }), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("tinymist.debuggerGetProgramName", (config) => { + return vscode.window.showInputBox({ + placeHolder: "Please enter the path to a typst file in the workspace folder", + value: findDefaultFileForDebugging(), + }); + + function findDefaultFileForDebugging() { + const editor = IContext.currentActiveEditor(); + const uri = editor?.document?.uri; + if (uri) { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); + if (workspaceFolder) { + return vscode.workspace.asRelativePath(uri, false); + } + } + + return "main.typ"; + } + }), + ); + + // register a configuration provider + const provider = new TypstConfigurationProvider(); + context.subscriptions.push( + vscode.debug.registerDebugConfigurationProvider(TYPST_DEBUGGER_TYPE, provider), + ); + + // register a dynamic configuration provider + context.subscriptions.push( + vscode.debug.registerDebugConfigurationProvider( + TYPST_DEBUGGER_TYPE, + { + provideDebugConfigurations(): ProviderResult { + return [ + { + name: "Launch Typst File", + request: "launch", + type: TYPST_DEBUGGER_TYPE, + program: "${file}", + }, + ]; + }, + }, + vscode.DebugConfigurationProviderTriggerKind.Dynamic, + ), + ); + + context.subscriptions.push( + vscode.debug.registerDebugAdapterDescriptorFactory(TYPST_DEBUGGER_TYPE, factory), + ); + if ("dispose" in factory) { + context.subscriptions.push(factory as any); + } +} + +class TypstConfigurationProvider implements vscode.DebugConfigurationProvider { + /** + * Massage a debug configuration just before a debug session is being launched, + * e.g. add all missing attributes to the debug configuration. + */ + resolveDebugConfiguration( + folder: WorkspaceFolder | undefined, + config: DebugConfiguration, + token?: CancellationToken, + ): ProviderResult { + // if launch.json is missing or empty + if (!config.type && !config.request && !config.name) { + const editor = vscode.window.activeTextEditor; + if (editor && editor.document.languageId === "typst") { + config.type = TYPST_DEBUGGER_TYPE; + config.name = "Launch"; + config.request = "launch"; + config.program = "${file}"; + config.stopOnEntry = true; + } + } + + if (!config.program) { + return vscode.window.showInformationMessage("Cannot find a program to debug").then((_) => { + return undefined; // abort launch + }); + } + + return config; + } +} diff --git a/editors/vscode/src/state.ts b/editors/vscode/src/state.ts index 9bf3c72f..bc2a5168 100644 --- a/editors/vscode/src/state.ts +++ b/editors/vscode/src/state.ts @@ -18,6 +18,7 @@ interface ExtensionState { preview: boolean; language: boolean; testing: boolean; + testingDebug: boolean; renderDocs: boolean; }; mut: { @@ -32,6 +33,7 @@ export const extensionState: ExtensionState = { features: { web: false, lsp: true, + testingDebug: true, task: true, wordSeparator: true, label: true, diff --git a/editors/vscode/src/test/e2e/debug.test.ts b/editors/vscode/src/test/e2e/debug.test.ts new file mode 100644 index 00000000..f26d413e --- /dev/null +++ b/editors/vscode/src/test/e2e/debug.test.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// import assert = require("assert"); +// import * as Path from "path"; +// import { DebugClient } from "@vscode/debugadapter-testsupport"; +// import { DebugProtocol } from "@vscode/debugprotocol"; + +// suite("Node Debug Adapter", () => { +// const DEBUG_ADAPTER = "./out/debugAdapter.js"; + +// const PROJECT_ROOT = Path.join(__dirname, "../../"); +// const DATA_ROOT = Path.join(PROJECT_ROOT, "src/tests/data/"); + +// let dc: DebugClient; + +// setup(() => { +// dc = new DebugClient("node", DEBUG_ADAPTER, "mock"); +// return dc.start(); +// }); + +// teardown(() => dc.stop()); + +// suite("basic", () => { +// test("unknown request should produce error", (done) => { +// dc.send("illegal_request") +// .then(() => { +// done(new Error("does not report error on unknown request")); +// }) +// .catch(() => { +// done(); +// }); +// }); +// }); + +// suite("initialize", () => { +// test("should return supported features", () => { +// return dc.initializeRequest().then((response) => { +// response.body = response.body || {}; +// assert.equal(response.body.supportsConfigurationDoneRequest, true); +// }); +// }); + +// test("should produce error for invalid 'pathFormat'", (done) => { +// dc.initializeRequest({ +// adapterID: "mock", +// linesStartAt1: true, +// columnsStartAt1: true, +// pathFormat: "url", +// }) +// .then((response) => { +// done(new Error("does not report error on invalid 'pathFormat' attribute")); +// }) +// .catch((err) => { +// // error expected +// done(); +// }); +// }); +// }); + +// suite("launch", () => { +// test("should run program to the end", () => { +// const PROGRAM = Path.join(DATA_ROOT, "test.md"); + +// return Promise.all([ +// dc.configurationSequence(), +// dc.launch({ program: PROGRAM }), +// dc.waitForEvent("terminated"), +// ]); +// }); + +// test("should stop on entry", () => { +// const PROGRAM = Path.join(DATA_ROOT, "test.md"); +// const ENTRY_LINE = 1; + +// return Promise.all([ +// dc.configurationSequence(), +// dc.launch({ program: PROGRAM, stopOnEntry: true }), +// dc.assertStoppedLocation("entry", { line: ENTRY_LINE }), +// ]); +// }); +// }); + +// suite("setBreakpoints", () => { +// test("should stop on a breakpoint", () => { +// const PROGRAM = Path.join(DATA_ROOT, "test.md"); +// const BREAKPOINT_LINE = 2; + +// return dc.hitBreakpoint({ program: PROGRAM }, { path: PROGRAM, line: BREAKPOINT_LINE }); +// }); + +// test("hitting a lazy breakpoint should send a breakpoint event", () => { +// const PROGRAM = Path.join(DATA_ROOT, "testLazyBreakpoint.md"); +// const BREAKPOINT_LINE = 3; + +// return Promise.all([ +// dc.hitBreakpoint( +// { program: PROGRAM }, +// { path: PROGRAM, line: BREAKPOINT_LINE, verified: false }, +// ), + +// dc.waitForEvent("breakpoint").then((event) => { +// const bpevent = event as DebugProtocol.BreakpointEvent; +// assert.strictEqual(bpevent.body.breakpoint.verified, true, "event mismatch: verified"); +// }), +// ]); +// }); +// }); + +// suite("setExceptionBreakpoints", () => { +// test("should stop on an exception", () => { +// const PROGRAM_WITH_EXCEPTION = Path.join(DATA_ROOT, "testWithException.md"); +// const EXCEPTION_LINE = 4; + +// return Promise.all([ +// dc +// .waitForEvent("initialized") +// .then((event) => { +// return dc.setExceptionBreakpointsRequest({ +// filters: ["otherExceptions"], +// }); +// }) +// .then((response) => { +// return dc.configurationDoneRequest(); +// }), + +// dc.launch({ program: PROGRAM_WITH_EXCEPTION }), + +// dc.assertStoppedLocation("exception", { line: EXCEPTION_LINE }), +// ]); +// }); +// }); +// }); +import * as vscode from "vscode"; +import type { Context } from "."; + +export async function getTests(ctx: Context) { + void vscode; +}