1use crate::{
2 config::{InitConfig, LspConfig},
3 lsp_ext::notification::{DidChangeSchemaAssociation, DidChangeSchemaAssociationParams},
4};
5use anyhow::anyhow;
6use arc_swap::ArcSwap;
7use lsp_async_stub::{rpc, util::Mapper, Context, RequestWriter};
8use lsp_types::Url;
9use once_cell::sync::Lazy;
10use regex::Regex;
11use serde_json::json;
12use std::{str, sync::Arc, time::Duration};
13use taplo::{dom::Node, parser::Parse};
14use taplo_common::{
15 config::Config,
16 environment::Environment,
17 schema::{
18 associations::{priority, source, AssociationRule, SchemaAssociation},
19 Schemas,
20 },
21 AsyncRwLock, HashMap, IndexMap,
22};
23
24pub type World<E> = Arc<WorldState<E>>;
25
26#[repr(transparent)]
27pub struct Workspaces<E: Environment>(IndexMap<Url, WorkspaceState<E>>);
28
29impl<E: Environment> std::ops::Deref for Workspaces<E> {
30 type Target = IndexMap<Url, WorkspaceState<E>>;
31
32 fn deref(&self) -> &Self::Target {
33 &self.0
34 }
35}
36
37impl<E: Environment> std::ops::DerefMut for Workspaces<E> {
38 fn deref_mut(&mut self) -> &mut Self::Target {
39 &mut self.0
40 }
41}
42
43impl<E: Environment> Workspaces<E> {
44 #[must_use]
45 pub fn by_document(&self, url: &Url) -> &WorkspaceState<E> {
46 self.0
47 .iter()
48 .filter(|(key, _)| url.as_str().starts_with(key.as_str()))
49 .max_by(|(a, _), (b, _)| a.as_str().len().cmp(&b.as_str().len()))
50 .map_or_else(
51 || {
52 tracing::warn!(document_url = %url, "using detached workspace");
53 self.0.get(&*DEFAULT_WORKSPACE_URL).unwrap()
54 },
55 |(_, ws)| ws,
56 )
57 }
58
59 pub fn by_document_mut(&mut self, url: &Url) -> &mut WorkspaceState<E> {
60 self.0
61 .iter_mut()
62 .filter(|(key, _)| {
63 url.as_str().starts_with(key.as_str()) || *key == &*DEFAULT_WORKSPACE_URL
64 })
65 .max_by(|(a, _), (b, _)| a.as_str().len().cmp(&b.as_str().len()))
66 .map(|(k, ws)| {
67 if k == &*DEFAULT_WORKSPACE_URL {
68 tracing::warn!(document_url = %url, "using detached workspace");
69 }
70
71 ws
72 })
73 .unwrap()
74 }
75}
76
77pub struct WorldState<E: Environment> {
78 pub(crate) init_config: ArcSwap<InitConfig>,
79 pub(crate) env: E,
80 pub(crate) workspaces: AsyncRwLock<Workspaces<E>>,
81 pub(crate) default_config: ArcSwap<Config>,
82}
83
84pub static DEFAULT_WORKSPACE_URL: Lazy<Url> = Lazy::new(|| Url::parse("root:///").unwrap());
85
86impl<E: Environment> WorldState<E> {
87 pub fn new(env: E) -> Self {
88 Self {
89 init_config: Default::default(),
90 workspaces: {
91 let mut m = IndexMap::default();
92 m.insert(
93 DEFAULT_WORKSPACE_URL.clone(),
94 WorkspaceState::new(env.clone(), DEFAULT_WORKSPACE_URL.clone()),
95 );
96 AsyncRwLock::new(Workspaces(m))
97 },
98 default_config: Default::default(),
99 env,
100 }
101 }
102
103 pub fn set_default_config(&self, default_config: Arc<Config>) {
105 self.default_config.store(default_config);
106 }
107}
108
109pub struct WorkspaceState<E: Environment> {
110 pub(crate) root: Url,
111 pub(crate) documents: HashMap<lsp_types::Url, DocumentState>,
112 pub(crate) taplo_config: Config,
113 pub(crate) schemas: Schemas<E>,
114 pub(crate) config: LspConfig,
115}
116
117impl<E: Environment> WorkspaceState<E> {
118 pub(crate) fn new(env: E, root: Url) -> Self {
119 let client;
120 #[cfg(target_arch = "wasm32")]
121 {
122 client = reqwest::Client::builder().build().unwrap();
123 }
124
125 #[cfg(not(target_arch = "wasm32"))]
126 {
127 client = taplo_common::util::get_reqwest_client(Duration::from_secs(10)).unwrap();
128 }
129
130 Self {
131 root,
132 documents: Default::default(),
133 taplo_config: Default::default(),
134 schemas: Schemas::new(env, client),
135 config: LspConfig::default(),
136 }
137 }
138}
139
140impl<E: Environment> WorkspaceState<E> {
141 pub(crate) fn document(&self, url: &Url) -> Result<&DocumentState, rpc::Error> {
142 self.documents
143 .get(url)
144 .ok_or_else(rpc::Error::invalid_params)
145 }
146
147 #[tracing::instrument(skip_all, fields(%self.root))]
148 pub(crate) async fn initialize(
149 &mut self,
150 context: Context<World<E>>,
151 env: &impl Environment,
152 ) -> Result<(), anyhow::Error> {
153 if let Err(error) = self
154 .load_config(env, &context.world().default_config.load())
155 .await
156 {
157 tracing::warn!(%error, "failed to load workspace configuration");
158 }
159
160 if !self.config.schema.enabled {
161 return Ok(());
162 }
163
164 self.schemas.cache().set_expiration_times(
165 Duration::from_secs(self.config.schema.cache.memory_expiration),
166 Duration::from_secs(self.config.schema.cache.disk_expiration),
167 );
168
169 self.schemas
170 .associations()
171 .add_from_config(&self.taplo_config);
172
173 for (pattern, schema_url) in &self.config.schema.associations {
174 let pattern = match Regex::new(pattern) {
175 Ok(p) => p,
176 Err(error) => {
177 tracing::error!(%error, "invalid association pattern");
178 continue;
179 }
180 };
181
182 let url = if schema_url.starts_with("./") {
183 self.root.join(schema_url)
184 } else {
185 schema_url.parse()
186 };
187
188 let url = match url {
189 Ok(u) => u,
190 Err(error) => {
191 tracing::error!(%error, url = %schema_url, "invalid schema url");
192 continue;
193 }
194 };
195
196 self.schemas.associations().add(
197 AssociationRule::Regex(pattern),
198 SchemaAssociation {
199 url,
200 meta: json!({
201 "source": source::LSP_CONFIG,
202 }),
203 priority: priority::LSP_CONFIG,
204 },
205 );
206 }
207
208 for catalog in &self.config.schema.catalogs {
209 if let Err(error) = self.schemas.associations().add_from_catalog(catalog).await {
210 tracing::error!(%error, "failed to add schemas from catalog");
211 }
212 }
213
214 self.emit_associations(context).await;
215 Ok(())
216 }
217
218 pub(crate) async fn load_config(
219 &mut self,
220 env: &impl Environment,
221 default_config: &Config,
222 ) -> Result<(), anyhow::Error> {
223 self.taplo_config = default_config.clone();
224
225 let root_path = env
226 .to_file_path_normalized(&self.root)
227 .ok_or_else(|| anyhow!("invalid root URL"))?;
228
229 if self.config.taplo.config_file.enabled {
230 let config_path = if let Some(p) = &self.config.taplo.config_file.path {
231 tracing::debug!(path = ?p, "using config file at specified path");
232
233 if env.is_absolute(p) {
234 Some(p.clone())
235 } else if self.root != *DEFAULT_WORKSPACE_URL {
236 Some(root_path.join(p))
237 } else {
238 tracing::debug!("relative config path is not valid for detached workspace");
239 None
240 }
241 } else if self.root != *DEFAULT_WORKSPACE_URL {
242 tracing::debug!("discovering config file in workspace");
243 env.find_config_file_normalized(&root_path).await
244 } else {
245 None
246 };
247
248 if let Some(config_path) = config_path {
249 tracing::info!(path = ?config_path, "using config file");
250 self.taplo_config =
251 toml::from_str(str::from_utf8(&env.read_file(&config_path).await?)?)?;
252 }
253 }
254
255 self.taplo_config.rule.extend(self.config.rules.clone());
256 self.taplo_config.prepare(env, &root_path)?;
257
258 tracing::debug!("using config: {:#?}", self.taplo_config);
259
260 Ok(())
261 }
262
263 pub(crate) async fn emit_associations(&self, mut context: Context<World<E>>) {
264 for document_url in self.documents.keys() {
265 if let Some(assoc) = self.schemas.associations().association_for(document_url) {
266 if let Err(error) = context
267 .write_notification::<DidChangeSchemaAssociation, _>(Some(
268 DidChangeSchemaAssociationParams {
269 document_uri: document_url.clone(),
270 schema_uri: Some(assoc.url.clone()),
271 meta: Some(assoc.meta.clone()),
272 },
273 ))
274 .await
275 {
276 tracing::error!(%error, "failed to write notification");
277 }
278 } else if let Err(error) = context
279 .write_notification::<DidChangeSchemaAssociation, _>(Some(
280 DidChangeSchemaAssociationParams {
281 document_uri: document_url.clone(),
282 schema_uri: None,
283 meta: None,
284 },
285 ))
286 .await
287 {
288 tracing::error!(%error, "failed to write notification");
289 }
290 }
291 }
292}
293
294#[derive(Debug, Clone)]
295pub struct DocumentState {
296 pub(crate) parse: Parse,
297 pub(crate) dom: Node,
298 pub(crate) mapper: Mapper,
299}