termesh_workspace/
language.rs1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use termesh_core::TaskSpec;
7use termesh_filesystem::{FileSystemService, FsError};
8use toml_edit::DocumentMut;
9
10const SETTINGS_PATH: &str = ".termesh/workspace.toml";
11
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
13pub struct LanguageSettings {
14 commands: BTreeMap<String, Vec<String>>,
15 tasks: BTreeMap<String, DeclaredTask>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19struct DeclaredTask {
20 label: String,
21 program: String,
22 args: Vec<String>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct LanguageSettingsError {
27 message: String,
28}
29
30impl std::fmt::Display for LanguageSettingsError {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 f.write_str(&self.message)
33 }
34}
35
36impl std::error::Error for LanguageSettingsError {}
37
38impl LanguageSettings {
39 pub fn parse(text: &str) -> Result<Self, LanguageSettingsError> {
40 let document = text.parse::<DocumentMut>().map_err(|error| LanguageSettingsError {
41 message: format!("invalid workspace settings: {error}"),
42 })?;
43 Self::from_document(&document)
44 }
45
46 pub fn load(fs: &dyn FileSystemService, root: &Path) -> Result<Self, FsError> {
47 let path = root.join(SETTINGS_PATH);
48 let bytes = match fs.read_file(&path) {
49 Ok(bytes) => bytes,
50 Err(FsError::NotFound(_)) => return Ok(Self::default()),
51 Err(error) => return Err(error),
52 };
53 let text = String::from_utf8(bytes)
54 .map_err(|_| settings_error(&path, "workspace settings are not valid UTF-8"))?;
55 Self::parse(&text).map_err(|error| settings_error(&path, error.to_string()))
56 }
57
58 pub fn command(&self, language: &str) -> Option<&Vec<String>> {
59 self.commands.get(language)
60 }
61
62 pub fn tasks(&self, root: &Path) -> Vec<TaskSpec> {
63 self.tasks
64 .iter()
65 .map(|(id, task)| TaskSpec {
66 id: format!("workspace.{id}"),
67 label: task.label.clone(),
68 program: task.program.clone(),
69 args: task.args.clone(),
70 cwd: root.into(),
71 })
72 .collect()
73 }
74
75 fn from_document(document: &DocumentMut) -> Result<Self, LanguageSettingsError> {
76 let mut commands = BTreeMap::new();
77 if let Some(lsp) = document.get("lsp") {
78 let lsp = lsp
79 .as_table()
80 .ok_or_else(|| LanguageSettingsError { message: "lsp must be a table".into() })?;
81 for (language, item) in lsp {
82 let table = item.as_table().ok_or_else(|| LanguageSettingsError {
83 message: format!("lsp.{language} must be a table"),
84 })?;
85 let Some(command) = table.get("command") else {
86 continue;
87 };
88 let array = command.as_array().ok_or_else(|| LanguageSettingsError {
89 message: format!("lsp.{language}.command must be an argv array"),
90 })?;
91 let command: Vec<String> = array
92 .iter()
93 .map(|argument| {
94 argument.as_str().map(str::to_string).ok_or_else(|| LanguageSettingsError {
95 message: format!(
96 "lsp.{language}.command arguments must all be strings"
97 ),
98 })
99 })
100 .collect::<Result<_, _>>()?;
101 if command.is_empty() || command[0].is_empty() {
102 return Err(LanguageSettingsError {
103 message: format!("lsp.{language}.command needs a program"),
104 });
105 }
106 commands.insert(language.to_string(), command);
107 }
108 }
109
110 let mut tasks = BTreeMap::new();
111 if let Some(declared) = document.get("tasks") {
112 let declared = declared
113 .as_table()
114 .ok_or_else(|| LanguageSettingsError { message: "tasks must be a table".into() })?;
115 for (id, item) in declared {
116 if id.is_empty() {
117 return Err(LanguageSettingsError {
118 message: "task id cannot be empty".into(),
119 });
120 }
121 let table = item.as_table().ok_or_else(|| LanguageSettingsError {
122 message: format!("tasks.{id} must be a table"),
123 })?;
124 let label = table.get("label").and_then(|item| item.as_str()).ok_or_else(|| {
125 LanguageSettingsError { message: format!("tasks.{id}.label must be a string") }
126 })?;
127 let program =
128 table.get("program").and_then(|item| item.as_str()).ok_or_else(|| {
129 LanguageSettingsError {
130 message: format!("tasks.{id}.program must be a string"),
131 }
132 })?;
133 if program.is_empty() {
134 return Err(LanguageSettingsError {
135 message: format!("tasks.{id}.program cannot be empty"),
136 });
137 }
138 let args = table.get("args").and_then(|item| item.as_array()).ok_or_else(|| {
139 LanguageSettingsError {
140 message: format!("tasks.{id}.args must be an argv array"),
141 }
142 })?;
143 let args = args
144 .iter()
145 .map(|argument| {
146 argument.as_str().map(str::to_string).ok_or_else(|| LanguageSettingsError {
147 message: format!("tasks.{id}.args must contain only strings"),
148 })
149 })
150 .collect::<Result<Vec<_>, _>>()?;
151 tasks.insert(
152 id.to_string(),
153 DeclaredTask { label: label.to_string(), program: program.to_string(), args },
154 );
155 }
156 }
157 Ok(Self { commands, tasks })
158 }
159}
160
161fn settings_error(path: &Path, message: impl Into<String>) -> FsError {
162 FsError::Other { path: PathBuf::from(path), message: message.into() }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use std::path::Path;
169 use termesh_test_support::FakeFileSystem;
170
171 #[test]
172 fn a_workspace_override_yields_the_raw_optional_command() {
173 let settings = LanguageSettings::parse(
174 r#"
175 [lsp.rust]
176 command = ["my-analyzer", "--stdio"]
177 "#,
178 )
179 .unwrap();
180 assert_eq!(
181 settings.command("rust"),
182 Some(&vec!["my-analyzer".to_string(), "--stdio".to_string()])
183 );
184 }
185
186 #[test]
187 fn a_malformed_override_reports_rather_than_replacing() {
188 let err = LanguageSettings::parse("[lsp.rust]\ncommand = \"one string\"\n")
189 .expect_err("a command must be an argv array");
190 assert!(err.to_string().contains("command"), "{err}");
191 }
192
193 #[test]
194 fn an_absent_settings_file_falls_back_to_the_default_recipe() {
195 let fs = FakeFileSystem::with_paths(&["/proj/Cargo.toml"]);
196 let settings = LanguageSettings::load(&fs, Path::new("/proj")).unwrap();
197 assert!(settings.command("rust").is_none());
198 }
199
200 #[test]
201 fn malformed_settings_name_the_file_and_reason() {
202 let fs = FakeFileSystem::new();
203 fs.add_file("/proj/.termesh/workspace.toml", b"[lsp.rust\n");
204 let error = LanguageSettings::load(&fs, Path::new("/proj")).unwrap_err();
205 assert!(error.to_string().contains("workspace.toml"), "{error}");
206 }
207
208 #[test]
209 fn workspace_declared_tasks_are_added_to_the_detected_ones() {
210 let settings = LanguageSettings::parse(
211 r#"
212 [tasks.smoke]
213 label = "Smoke"
214 program = "make"
215 args = ["smoke"]
216 "#,
217 )
218 .unwrap();
219
220 let declared = settings.tasks(Path::new("/p"));
221
222 assert_eq!(declared[0].id, "workspace.smoke");
223 assert_eq!(declared[0].program, "make");
224 assert_eq!(declared[0].args, vec!["smoke"]);
225 }
226
227 #[test]
228 fn a_declared_task_must_use_an_argv_array() {
229 let error = LanguageSettings::parse(
230 "[tasks.a]\nlabel = \"A\"\nprogram = \"make\"\nargs = \"smoke\"\n",
231 )
232 .expect_err("args is an argv array, never a shell string");
233 assert!(error.to_string().contains("args"), "{error}");
234 }
235}