Skip to main content

termesh_workspace/
language.rs

1//! Workspace-local language-server command overrides.
2
3use 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}