feat: support formatters (#113)

* feat: supports formatter

* feat: supports dynamic configuration and typstfmt

* fix: test
This commit is contained in:
Myriad-Dreamin 2024-03-28 16:28:00 +08:00 committed by GitHub
parent 0114bf4a3b
commit d4dda9e06f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 663 additions and 216 deletions

471
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -53,15 +53,17 @@ reflexo = { version = "0.5.0-rc2", default-features = false, features = [
typst-ts-core = { version = "0.5.0-rc2", default-features = false }
typst-ts-compiler = { version = "0.5.0-rc2" }
typst-preview = { version = "0.11.3" }
typstyle = "0.11.4"
typstfmt_lib = { git = "https://github.com/astrale-sharp/typstfmt", tag = "0.2.7" }
lsp-server = "0.7.6"
lsp-types = { version = "=0.95.0", features = ["proposed"] }
crossbeam-channel = "0.5.12"
clap = { version = "4.4", features = ["derive", "env", "unicode", "wrap_help"] }
clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] }
clap_builder = { version = "4", features = ["string"] }
clap_complete = "4.4"
clap_complete_fig = "4.4"
clap_complete = "4.5"
clap_complete_fig = "4.5"
clap_mangen = { version = "0.2.15" }
vergen = { version = "8.2.5", features = [
"build",

View file

@ -109,6 +109,7 @@ pub trait StatefulRequest {
#[allow(missing_docs)]
mod polymorphic {
use lsp_types::TextEdit;
use serde::{Deserialize, Serialize};
use super::prelude::*;
@ -150,6 +151,12 @@ mod polymorphic {
pub path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct FormattingRequest {
/// The path of the document to get semantic tokens for.
pub path: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FoldRequestFeature {
PinnedFirst,
@ -176,6 +183,7 @@ mod polymorphic {
Symbol(SymbolRequest),
SemanticTokensFull(SemanticTokensFullRequest),
SemanticTokensDelta(SemanticTokensDeltaRequest),
Formatting(FormattingRequest),
FoldingRange(FoldingRangeRequest),
SelectionRange(SelectionRangeRequest),
}
@ -200,6 +208,7 @@ mod polymorphic {
CompilerQueryRequest::Symbol(..) => Mergable,
CompilerQueryRequest::SemanticTokensFull(..) => ContextFreeUnique,
CompilerQueryRequest::SemanticTokensDelta(..) => ContextFreeUnique,
CompilerQueryRequest::Formatting(..) => ContextFreeUnique,
CompilerQueryRequest::FoldingRange(..) => ContextFreeUnique,
CompilerQueryRequest::SelectionRange(..) => ContextFreeUnique,
}
@ -223,6 +232,7 @@ mod polymorphic {
CompilerQueryRequest::Symbol(..) => return None,
CompilerQueryRequest::SemanticTokensFull(req) => &req.path,
CompilerQueryRequest::SemanticTokensDelta(req) => &req.path,
CompilerQueryRequest::Formatting(req) => &req.path,
CompilerQueryRequest::FoldingRange(req) => &req.path,
CompilerQueryRequest::SelectionRange(req) => &req.path,
})
@ -247,6 +257,7 @@ mod polymorphic {
Symbol(Option<Vec<SymbolInformation>>),
SemanticTokensFull(Option<SemanticTokensResult>),
SemanticTokensDelta(Option<SemanticTokensFullDeltaResult>),
Formatting(Option<Vec<TextEdit>>),
FoldingRange(Option<Vec<FoldingRange>>),
SelectionRange(Option<Vec<SelectionRange>>),
}

View file

@ -42,6 +42,9 @@ typst-render.workspace = true
typst-timing.workspace = true
typst-assets = { workspace = true, features = ["fonts"] }
typstyle.workspace = true
typstfmt_lib.workspace = true
typst-ts-core = { workspace = true, default-features = false, features = [
"flat-vector",
"vector-bbox",

View file

@ -1,6 +1,7 @@
//! Bootstrap actors for Tinymist.
pub mod cluster;
mod formatting;
pub mod render;
pub mod typ_client;
pub mod typ_server;
@ -14,6 +15,7 @@ use typst_ts_compiler::{
use typst_ts_core::config::compiler::EntryState;
use self::{
formatting::run_format_thread,
render::{ExportActor, ExportConfig},
typ_client::{CompileClientActor, CompileDriver, CompileHandler},
typ_server::CompileServerActor,
@ -24,6 +26,8 @@ use crate::{
TypstLanguageServer,
};
pub use formatting::{FormattingConfig, FormattingRequest};
type CompileDriverInner = CompileDriverImpl<LspWorld>;
impl CompileServer {
@ -113,4 +117,20 @@ impl TypstLanguageServer {
) -> CompileClientActor {
self.primary.server(diag_group, entry, inputs)
}
pub fn run_format_thread(&mut self) {
if self.format_thread.is_some() {
log::error!("formatting thread already started");
return;
}
let (tx_req, rx_req) = crossbeam_channel::unbounded();
self.format_thread = Some(tx_req.clone());
let client = self.client.clone();
let mode = self.config.formatter;
std::thread::spawn(move || {
run_format_thread(FormattingConfig { mode, width: 120 }, rx_req, client)
});
}
}

View file

@ -0,0 +1,91 @@
use lsp_server::RequestId;
use lsp_types::{Position, Range, TextEdit};
use typst::syntax::Source;
use crate::{result_to_response_, FormatterMode, LspHost, LspResult, TypstLanguageServer};
#[derive(Debug, Clone)]
pub struct FormattingConfig {
pub mode: FormatterMode,
pub width: u32,
}
pub enum FormattingRequest {
ChangeConfig(FormattingConfig),
Formatting((RequestId, Source)),
}
pub fn run_format_thread(
init_c: FormattingConfig,
rx_req: crossbeam_channel::Receiver<FormattingRequest>,
client: LspHost<TypstLanguageServer>,
) {
type FmtFn = Box<dyn Fn(Source) -> LspResult<Option<Vec<TextEdit>>>>;
let compile = |c: FormattingConfig| -> FmtFn {
log::info!("formatting thread with config: {c:#?}");
match c.mode {
FormatterMode::Typstyle => {
let cw = c.width as usize;
let f: FmtFn = Box::new(move |e: Source| {
let res = typstyle_core::pretty_print(e.text(), cw);
Ok(Some(vec![TextEdit {
new_text: res,
range: Range::new(
Position {
line: 0,
character: 0,
},
Position {
line: u32::MAX,
character: u32::MAX,
},
),
}]))
});
f
}
FormatterMode::Typstfmt => {
let config = typstfmt_lib::Config {
max_line_length: 120,
..typstfmt_lib::Config::default()
};
let f: FmtFn = Box::new(move |e: Source| {
let res = typstfmt_lib::format(e.text(), config);
Ok(Some(vec![TextEdit {
new_text: res,
range: Range::new(
Position {
line: 0,
character: 0,
},
Position {
line: u32::MAX,
character: u32::MAX,
},
),
}]))
});
f
}
FormatterMode::Disable => {
let f: FmtFn = Box::new(|_| Ok(None));
f
}
}
};
let mut f: FmtFn = compile(init_c);
while let Ok(req) = rx_req.recv() {
match req {
FormattingRequest::ChangeConfig(c) => f = compile(c),
FormattingRequest::Formatting((id, source)) => {
let res = f(source);
if let Ok(response) = result_to_response_(id, res) {
client.respond(response);
}
}
}
}
log::info!("formatting thread did shut down");
}

View file

@ -37,7 +37,7 @@ use crossbeam_channel::select;
use crossbeam_channel::Receiver;
use futures::future::BoxFuture;
use log::{error, info, trace, warn};
use lsp_server::{ErrorCode, Message, Notification, Request, ResponseError};
use lsp_server::{ErrorCode, Message, Notification, Request, RequestId, ResponseError};
use lsp_types::notification::Notification as NotificationTrait;
use lsp_types::request::{GotoDeclarationParams, GotoDeclarationResponse, WorkspaceConfiguration};
use lsp_types::*;
@ -60,6 +60,7 @@ use typst_ts_core::{error::prelude::*, ImmutPath};
use super::lsp_init::*;
use crate::actor::render::ExportConfig;
use crate::actor::typ_client::CompileClientActor;
use crate::actor::{FormattingConfig, FormattingRequest};
use crate::compiler::{CompileServer, CompileServerArgs};
use crate::compiler_init::CompilerConstConfig;
use crate::harness::{InitializedLspDriver, LspHost};
@ -83,14 +84,20 @@ impl fmt::Display for Event {
}
}
struct Cancelled;
pub(crate) struct Cancelled;
type LspMethod<Res> = fn(srv: &mut TypstLanguageServer, args: JsonValue) -> LspResult<Res>;
type LspHandler<Req, Res> = fn(srv: &mut TypstLanguageServer, args: Req) -> LspResult<Res>;
/// Returns Ok(Some()) -> Already responded
/// Returns Ok(None) -> Need to respond none
/// Returns Err(..) -> Need t o respond error
type LspRawHandler =
fn(srv: &mut TypstLanguageServer, args: (RequestId, JsonValue)) -> LspResult<Option<()>>;
type ExecuteCmdMap = HashMap<&'static str, LspHandler<Vec<JsonValue>, JsonValue>>;
type NotifyCmdMap = HashMap<&'static str, LspMethod<()>>;
type RegularCmdMap = HashMap<&'static str, LspMethod<JsonValue>>;
type RegularCmdMap = HashMap<&'static str, LspRawHandler>;
macro_rules! exec_fn {
($ty: ty, Self::$method: ident, $($arg_key:ident),+ $(,)?) => {{
@ -99,15 +106,44 @@ macro_rules! exec_fn {
}};
}
macro_rules! request_fn_ {
($desc: ty, Self::$method: ident) => {
(<$desc>::METHOD, {
const E: LspRawHandler = |this, (req_id, req)| {
let req: <$desc as lsp_types::request::Request>::Params =
serde_json::from_value(req).unwrap(); // todo: soft unwrap
this.$method(req_id, req)
};
E
})
};
}
macro_rules! request_fn {
($desc: ty, Self::$method: ident) => {
(<$desc>::METHOD, {
const E: LspMethod<JsonValue> = |this, req| {
const E: LspRawHandler = |this, (req_id, req)| {
let req: <$desc as lsp_types::request::Request>::Params =
serde_json::from_value(req).unwrap(); // todo: soft unwrap
let res = this.$method(req)?;
let res = serde_json::to_value(res).unwrap(); // todo: soft unwrap
Ok(res)
let res = this
.$method(req)
.map(|res| serde_json::to_value(res).unwrap()); // todo: soft unwrap
if let Ok(response) = result_to_response(req_id, res) {
this.client.respond(response);
}
// todo: cancellation
// Err(e) => match e.downcast::<Cancelled>() {
// Ok(cancelled) => return Err(cancelled),
// Err(e) => lsp_server::Response::new_err(
// id,
// lsp_server::ErrorCode::InternalError as i32,
// e.to_string(),
// ),
// },
Ok(Some(()))
};
E
})
@ -152,6 +188,7 @@ pub struct TypstLanguageServer {
/// Whether the server is shutting down.
pub shutdown_requested: bool,
pub sema_tokens_registered: Option<bool>,
pub formatter_registered: Option<bool>,
// Configurations
/// User configuration from the editor.
@ -175,6 +212,7 @@ pub struct TypstLanguageServer {
pub primary: CompileServer,
pub main: Option<CompileClientActor>,
pub tokens_ctx: SemanticTokenContext,
pub format_thread: Option<crossbeam_channel::Sender<FormattingRequest>>,
}
/// Getters and the main loop.
@ -200,6 +238,7 @@ impl TypstLanguageServer {
}),
shutdown_requested: false,
sema_tokens_registered: None,
formatter_registered: None,
config: Default::default(),
const_config: args.const_config,
compile_opts: args.compile_opts,
@ -212,6 +251,7 @@ impl TypstLanguageServer {
pinning: false,
main: None,
tokens_ctx,
format_thread: None,
}
}
@ -238,6 +278,7 @@ impl TypstLanguageServer {
request_fn!(SemanticTokensFullDeltaRequest, Self::semantic_tokens_full_delta),
request_fn!(DocumentSymbolRequest, Self::document_symbol),
// Sync for low latency
request_fn_!(Formatting, Self::formatting),
request_fn!(SelectionRangeRequest, Self::selection_range),
// latency insensitive
request_fn!(InlayHintRequest, Self::inlay_hint),
@ -287,6 +328,15 @@ impl InitializedLspDriver for TypstLanguageServer {
}
}
if self.const_config().doc_fmt_dynamic_registration
&& self.config.formatter != FormatterMode::Disable
{
let err = self.react_formatter_changes(true);
if let Err(err) = err {
error!("could not register formatter for initialization: {err}");
}
}
if self.const_config().cfg_change_registration {
trace!("setting up to request config change notifications");
@ -394,30 +444,13 @@ impl TypstLanguageServer {
return;
};
let result = handler(self, req.params);
if let Ok(response) = result_to_response(req.id, result) {
self.client.respond(response);
let res = handler(self, (req.id.clone(), req.params));
if matches!(res, Ok(Some(()))) {
return;
}
// todo: cancellation
// Err(e) => match e.downcast::<Cancelled>() {
// Ok(cancelled) => return Err(cancelled),
// Err(e) => lsp_server::Response::new_err(
// id,
// lsp_server::ErrorCode::InternalError as i32,
// e.to_string(),
// ),
// },
fn result_to_response(
id: lsp_server::RequestId,
result: Result<JsonValue, ResponseError>,
) -> Result<lsp_server::Response, Cancelled> {
let res = match result {
Ok(resp) => lsp_server::Response::new_ok(id, resp),
Err(e) => lsp_server::Response::new_err(id, e.code, e.message),
};
Ok(res)
if let Ok(response) = result_to_response_(req.id, res) {
self.client.respond(response);
}
}
@ -481,6 +514,53 @@ impl TypstLanguageServer {
res
}
fn react_formatter_changes(&mut self, enable: bool) -> anyhow::Result<()> {
if !self.const_config().doc_fmt_dynamic_registration {
trace!("skip dynamic register formatter by config");
return Ok(());
}
const FORMATTING_REGISTRATION_ID: &str = "formatting";
const DOCUMENT_FORMATTING_METHOD_ID: &str = "textDocument/formatting";
pub fn get_formatting_registration() -> Registration {
Registration {
id: FORMATTING_REGISTRATION_ID.to_owned(),
method: DOCUMENT_FORMATTING_METHOD_ID.to_owned(),
register_options: None,
}
}
pub fn get_formatting_unregistration() -> Unregistration {
Unregistration {
id: FORMATTING_REGISTRATION_ID.to_owned(),
method: DOCUMENT_FORMATTING_METHOD_ID.to_owned(),
}
}
let res = match (enable, self.formatter_registered) {
(true, None | Some(false)) => {
trace!("registering formatter");
self.client
.register_capability(vec![get_formatting_registration()])
.context("could not register formatter")
}
(false, Some(true)) => {
trace!("unregistering formatter");
self.client
.unregister_capability(vec![get_formatting_unregistration()])
.context("could not unregister formatter")
}
(true, Some(true)) | (false, None | Some(false)) => Ok(()),
};
if res.is_ok() {
self.formatter_registered = Some(enable);
}
res
}
}
/// Trait implemented by language server backends.
@ -823,6 +903,22 @@ impl TypstLanguageServer {
}
}
if config.formatter != self.config.formatter {
let err = self.react_formatter_changes(self.config.formatter != FormatterMode::Disable);
if let Err(err) = err {
error!("could not change formatter config: {err}");
}
if let Some(f) = &self.format_thread {
let err = f.send(FormattingRequest::ChangeConfig(FormattingConfig {
mode: self.config.formatter,
width: 120,
}));
if let Err(err) = err {
error!("could not change formatter config: {err}");
}
}
}
// todo: watch changes of the root path
Ok(())
@ -929,6 +1025,26 @@ impl TypstLanguageServer {
run_query!(self.SemanticTokensDelta(path, previous_result_id))
}
fn formatting(
&self,
req_id: RequestId,
params: DocumentFormattingParams,
) -> LspResult<Option<()>> {
if matches!(self.config.formatter, FormatterMode::Disable) {
return Ok(None);
}
let path = as_path(params.text_document);
self.query_source(&path, |source| {
if let Some(f) = &self.format_thread {
f.send(FormattingRequest::Formatting((req_id, source.clone())))?;
}
Ok(Some(()))
})
.map_err(|e| internal_error(format!("could not format document: {e}")))
}
fn inlay_hint(&self, params: InlayHintParams) -> LspResult<Option<Vec<InlayHint>>> {
let path = as_path(params.text_document);
let range = params.range;
@ -998,3 +1114,31 @@ pub fn method_not_found() -> ResponseError {
data: None,
}
}
pub(crate) fn result_to_response_<T: Serialize>(
id: lsp_server::RequestId,
result: Result<T, ResponseError>,
) -> Result<lsp_server::Response, Cancelled> {
let res = match result {
Ok(resp) => {
let resp = serde_json::to_value(resp);
match resp {
Ok(resp) => lsp_server::Response::new_ok(id, resp),
Err(e) => return result_to_response(id, Err(internal_error(e.to_string()))),
}
}
Err(e) => lsp_server::Response::new_err(id, e.code, e.message),
};
Ok(res)
}
fn result_to_response(
id: lsp_server::RequestId,
result: Result<JsonValue, ResponseError>,
) -> Result<lsp_server::Response, Cancelled> {
let res = match result {
Ok(resp) => lsp_server::Response::new_ok(id, resp),
Err(e) => lsp_server::Response::new_err(id, e.code, e.message),
};
Ok(res)
}

View file

@ -24,15 +24,17 @@ use crate::{
// LocationLink[] even if the client does not report the
// textDocument.definition.linkSupport capability.
/// The mode of the experimental formatter.
/// The mode of the formatter.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ExperimentalFormatterMode {
/// Disable the experimental formatter.
pub enum FormatterMode {
/// Disable the formatter.
#[default]
Disable,
/// Enable the experimental formatter.
Enable,
/// Use `typstyle` formatter.
Typstyle,
/// Use `typstfmt` formatter.
Typstfmt,
}
/// The mode of PDF/SVG/PNG export.
@ -83,7 +85,7 @@ const CONFIG_ITEMS: &[&str] = &[
"exportPdf",
"rootPath",
"semanticTokens",
"experimentalFormatterMode",
"formatterMode",
"typstExtraArgs",
];
@ -95,7 +97,7 @@ pub struct Config {
/// Dynamic configuration for semantic tokens.
pub semantic_tokens: SemanticTokensMode,
/// Dynamic configuration for the experimental formatter.
pub formatter: ExperimentalFormatterMode,
pub formatter: FormatterMode,
}
impl Config {
@ -153,8 +155,8 @@ impl Config {
}
let formatter = update
.get("experimentalFormatterMode")
.map(ExperimentalFormatterMode::deserialize)
.get("formatterMode")
.map(FormatterMode::deserialize)
.and_then(Result::ok);
if let Some(formatter) = formatter {
self.formatter = formatter;
@ -354,6 +356,8 @@ impl Init {
service.primary.config = config.compile.clone();
service.config = config;
service.run_format_thread();
let cluster_actor = CompileClusterActor {
host: self.host.clone(),
diag_rx,
@ -383,8 +387,11 @@ impl Init {
_ => None,
};
// if !cc.doc_fmt_dynamic_registration
let document_formatting_provider = match service.config.formatter {
ExperimentalFormatterMode::Enable if !cc.doc_fmt_dynamic_registration => {
FormatterMode::Typstyle | FormatterMode::Typstfmt
if !cc.doc_fmt_dynamic_registration =>
{
Some(OneOf::Left(true))
}
_ => None,
@ -480,7 +487,7 @@ mod tests {
"exportPdf": "onSave",
"rootPath": root_path,
"semanticTokens": "enable",
"experimentalFormatterMode": "enable",
"formatterMode": "typstyle",
"typstExtraArgs": ["--root", root_path]
});
@ -490,7 +497,7 @@ mod tests {
assert_eq!(config.compile.export_pdf, ExportMode::OnSave);
assert_eq!(config.compile.root_path, Some(PathBuf::from(root_path)));
assert_eq!(config.semantic_tokens, SemanticTokensMode::Enable);
assert_eq!(config.formatter, ExperimentalFormatterMode::Enable);
assert_eq!(config.formatter, FormatterMode::Typstyle);
assert_eq!(
config.compile.typst_extra_args,
Some(CompileExtraOpts {

View file

@ -1,6 +1,6 @@
//! Bootstrap actors for Tinymist.
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use ::typst::{diag::FileResult, syntax::Source};
use anyhow::anyhow;
@ -162,16 +162,13 @@ macro_rules! run_query {
}
macro_rules! query_source {
($self:ident, $method:ident, $req:expr) => {{
let path: ImmutPath = $req.path.clone().into();
let snapshot = $self.memory_changes.get(&path);
let snapshot = snapshot.ok_or_else(|| anyhow!("file missing {:?}", path))?;
let source = snapshot.content.clone();
($self:ident, $method:ident, $req:expr) => {
$self.query_source(&$req.path.clone(), |source| {
let enc = $self.const_config.position_encoding;
let res = $req.request(&source, enc);
Ok(CompilerQueryResponse::$method(res))
}};
})
};
}
macro_rules! query_tokens_cache {
@ -201,6 +198,18 @@ macro_rules! query_world {
}
impl TypstLanguageServer {
pub fn query_source<T>(
&self,
p: &Path,
f: impl FnOnce(Source) -> anyhow::Result<T>,
) -> anyhow::Result<T> {
let path: ImmutPath = p.into();
let snapshot = self.memory_changes.get(&path);
let snapshot = snapshot.ok_or_else(|| anyhow!("file missing {:?}", path))?;
let source = snapshot.content.clone();
f(source)
}
pub fn query(&self, query: CompilerQueryRequest) -> anyhow::Result<CompilerQueryResponse> {
use CompilerQueryRequest::*;
@ -254,6 +263,7 @@ impl TypstLanguageServer {
FoldingRange(..)
| SelectionRange(..)
| SemanticTokensDelta(..)
| Formatting(..)
| DocumentSymbol(..)
| SemanticTokensFull(..) => unreachable!(),
}

View file

@ -121,18 +121,20 @@
"default": "off",
"description": "Traces the communication between VS Code and the language server."
},
"tinymist.experimentalFormatterMode": {
"tinymist.formatterMode": {
"title": "Enable Experimental Formatter",
"description": "The extension can format Typst files using typstfmt (experimental).",
"description": "The extension can format Typst files using typstfmt or typstyle.",
"type": "string",
"default": "disable",
"enum": [
"disable",
"enable"
"typstyle",
"typstfmt"
],
"enumDescriptions": [
"Formatter is not activated.",
"Experimental formatter is activated."
"Use typstyle formatter.",
"Use typstfmt formatter."
]
}
}