asm_lsp/
config_builder.rs

1use std::path::Path;
2use std::string::ToString;
3use std::{env::current_dir, path::PathBuf};
4
5use anyhow::{Result, anyhow};
6use clap::{Args, arg};
7use dialoguer::{Confirm, FuzzySelect, Input, theme::ColorfulTheme};
8use dirs::config_dir;
9
10use crate::types::{Arch, Assembler, Config, ConfigOptions, ProjectConfig, RootConfig};
11
12const ARCH_LIST: [Arch; 11] = [
13    Arch::X86,
14    Arch::X86_64,
15    Arch::X86_AND_X86_64,
16    Arch::ARM,
17    Arch::ARM64,
18    Arch::RISCV,
19    Arch::Z80,
20    Arch::MOS6502,
21    Arch::PowerISA,
22    Arch::Avr,
23    Arch::Mips,
24];
25
26const ASSEMBLER_LIST: [Assembler; 8] = [
27    Assembler::Gas,
28    Assembler::Go,
29    Assembler::Mars,
30    Assembler::Masm,
31    Assembler::Nasm,
32    Assembler::Ca65,
33    Assembler::Avr,
34    Assembler::Fasm,
35];
36
37#[derive(Args, Debug, Clone)]
38#[command(about = "Generate a .asm-lsp.toml config file")]
39pub struct GenerateArgs {
40    #[arg(
41        long,
42        short,
43        help = "Directory to place .asm-lsp.toml into. (Default is the current directory)"
44    )]
45    pub output_dir: Option<PathBuf>,
46    #[arg(
47        long,
48        short,
49        conflicts_with = "output_dir",
50        help = "Place the config in the global config directory"
51    )]
52    pub global_cfg: bool,
53    #[arg(
54        long,
55        short,
56        conflicts_with = "global_cfg",
57        help = "Path to the project this config is being generated for. (Default is the current directory)"
58    )]
59    pub project_path: Option<PathBuf>,
60    #[arg(
61        short = 'w',
62        long,
63        help = "Overwrite any existing .asm-lsp.toml in the target directory"
64    )]
65    pub overwrite: bool,
66    #[arg(
67        short,
68        long,
69        help = "Don't display the generated config file after generation"
70    )]
71    pub quiet: bool,
72}
73
74#[derive(Debug, Clone)]
75pub struct GenerateOpts {
76    pub output_path: PathBuf,
77    pub project_path: PathBuf,
78    pub overwrite: bool,
79    pub quiet: bool,
80}
81
82impl TryFrom<GenerateArgs> for GenerateOpts {
83    type Error = String;
84    fn try_from(value: GenerateArgs) -> Result<Self, std::string::String> {
85        let output_path = {
86            if value.global_cfg {
87                let mut path = config_dir().ok_or_else(|| "Failed to detect config directory, try specifying it manually with `--output_dir`".to_string())?;
88                path.push("asm-lsp");
89                path.push(".asm-lsp.toml");
90                path
91            } else if let Some(path) = value.output_dir.as_ref() {
92                let mut canonicalized_path = path.canonicalize().map_err(|e| {
93                    format!(
94                        "Failed to canonicalize target path: \"{}\" -- {e}",
95                        path.display()
96                    )
97                })?;
98                if !canonicalized_path.is_dir() {
99                    let gave_file_name = canonicalized_path.ends_with(".asm-lsp.toml");
100                    return Err(format!(
101                        "Target path \"{}\" is not a directory.{}",
102                        canonicalized_path.display(),
103                        if gave_file_name {
104                            " Hint: Don't include the filename \".asm-lsp.toml\" at the end of your target path."
105                        } else {
106                            ""
107                        }
108                    ));
109                }
110                canonicalized_path.push(".asm-lsp.toml");
111                canonicalized_path
112            } else {
113                let mut path = current_dir()
114                    .map_err(|e| format!("Failed to detect current directory -- {e}"))?;
115                path.push(".asm-lsp.toml");
116                path
117            }
118        };
119        let project_path = {
120            if let Some(path) = value.project_path.as_ref().or(value.output_dir.as_ref()) {
121                let canonicalized_path = path.canonicalize().map_err(|e| {
122                    format!(
123                        "Failed to canonicalize project path: \"{}\" -- {e}",
124                        path.display()
125                    )
126                })?;
127                if !canonicalized_path.is_dir() {
128                    return Err(format!(
129                        "Project path \"{}\" is not a directory.",
130                        canonicalized_path.display(),
131                    ));
132                }
133                canonicalized_path
134            } else {
135                current_dir().map_err(|e| format!("Failed to detect current directory -- {e}"))?
136            }
137        };
138
139        Ok(Self {
140            output_path,
141            project_path,
142            overwrite: value.overwrite,
143            quiet: value.quiet,
144        })
145    }
146}
147
148fn prompt_arch() -> Arch {
149    let arch_choices: Vec<String> = ARCH_LIST.iter().map(ToString::to_string).collect();
150    let arch_selection = FuzzySelect::with_theme(&ColorfulTheme::default())
151        .with_prompt("Select architecture")
152        .default(0)
153        .items(&arch_choices[..])
154        .interact()
155        .unwrap();
156
157    ARCH_LIST[arch_selection]
158}
159
160fn prompt_assembler() -> Assembler {
161    let assem_choices: Vec<String> = ASSEMBLER_LIST.iter().map(ToString::to_string).collect();
162    let assem_selection = FuzzySelect::with_theme(&ColorfulTheme::default())
163        .with_prompt("Select assembler")
164        .default(0)
165        .items(&assem_choices[..])
166        .interact()
167        .unwrap();
168
169    ASSEMBLER_LIST[assem_selection]
170}
171
172fn prompt_project_path(opts: &GenerateOpts) -> PathBuf {
173    println!("Provide a project path:");
174    let fallback_enter = |true_path: &mut PathBuf| {
175        println!(
176            "Warning: Failed to create directory reader for path \"{}\"",
177            true_path.display()
178        );
179        let remaining_path: String = Input::with_theme(&ColorfulTheme::default())
180            .with_prompt("Enter remaining path (Enter an empty string to use the current path)")
181            .allow_empty(true)
182            .interact_text()
183            .unwrap();
184        true_path.push(remaining_path);
185    };
186    let mut true_path = opts.project_path.clone();
187    let mut display_entries = Vec::new();
188    let mut path_entries = Vec::new();
189    loop {
190        let selection_text = format!("{}", true_path.display());
191        // Dummy entry to account for the accept option as the first displayed
192        // option
193        path_entries.push(PathBuf::new());
194        display_entries.push("<Select This Directory>".to_string());
195        let Ok(dir_reader) = std::fs::read_dir(&true_path) else {
196            fallback_enter(&mut true_path);
197            return true_path;
198        };
199
200        let mut dir_entries = Vec::new();
201        let mut file_entries = Vec::new();
202        for entry in dir_reader.filter_map(std::result::Result::ok) {
203            let entry_path = entry.path();
204            if entry_path.is_dir() {
205                dir_entries.push(entry_path);
206            } else {
207                file_entries.push(entry_path);
208            }
209        }
210
211        dir_entries.sort();
212        file_entries.sort();
213
214        for entry_path in dir_entries.into_iter().chain(file_entries) {
215            path_entries.push(entry_path.clone());
216            if let Some(name) = entry_path.file_name() {
217                display_entries.push(name.to_string_lossy().to_string());
218            }
219        }
220        let path_selection = FuzzySelect::with_theme(&ColorfulTheme::default())
221            .with_prompt(&selection_text)
222            .default(0)
223            .items(&display_entries[..])
224            .interact()
225            .unwrap();
226
227        // Select current value of `true_path`
228        if path_selection == 0 {
229            if true_path.to_string_lossy().len() == opts.project_path.to_string_lossy().len() &&
230                !Confirm::with_theme(&ColorfulTheme::default())
231                    .with_prompt("Warning: Creating project config for the entire project. Keep this path selection?")
232                    .default(false)
233                    .interact()
234                    .unwrap() {
235                path_entries.clear();
236                display_entries.clear();
237                continue;
238            }
239            break;
240        }
241
242        true_path.clone_from(&path_entries[path_selection]);
243        if true_path.is_file() {
244            break;
245        }
246        path_entries.clear();
247        display_entries.clear();
248    }
249
250    // Get the project-relative path out for the sake of portability, i.e. if a
251    // user changes the location of their project, their config should still work
252    let mut relative_path = PathBuf::new();
253    for comp in true_path
254        .components()
255        .skip(opts.project_path.components().count())
256    {
257        relative_path.push(comp);
258    }
259
260    relative_path
261}
262
263fn prompt_project(opts: &GenerateOpts) -> ProjectConfig {
264    let path = prompt_project_path(opts);
265    let config = prompt_config();
266
267    ProjectConfig { path, config }
268}
269
270/// Check if a path points to an executable file
271fn is_executable(path: &Path) -> bool {
272    if path.is_file() {
273        #[cfg(unix)]
274        {
275            // On Unix, check the `x` bit
276            use std::fs;
277            use std::os::unix::fs::PermissionsExt;
278            let metadata = fs::metadata(path).unwrap();
279            metadata.permissions().mode() & 0o111 != 0
280        }
281        #[cfg(windows)]
282        {
283            // On Windows, check for common executable extensions
284            let extensions = ["exe", "cmd", "bat", "com"];
285            if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
286                return extensions.contains(&ext);
287            }
288            false
289        }
290    } else {
291        #[cfg(windows)]
292        {
293            // On Windows, it's valid to omit file extensions, i.e. `gcc` can be
294            // used to designate `gcc.exe`. However, this will cause `.is_file()`
295            // to return `false`, so we need to check for this case here rather
296            // than above
297            let extensions = ["exe", "cmd", "bat", "com"];
298            for ext in &extensions {
299                let Some(path) = path.to_str() else {
300                    continue;
301                };
302                let ext_path = PathBuf::from(format!("{path}.{ext}"));
303                if ext_path.exists() && ext_path.is_file() {
304                    println!(
305                        "Warning: Extended provided path with \".{ext}\" in order to find valid compiler"
306                    );
307                    return true;
308                }
309            }
310        }
311        false
312    }
313}
314
315/// Check if `cmd` has a corresponding executable file on $PATH
316#[must_use]
317fn is_executable_on_path(cmd: &str) -> bool {
318    use std::env;
319    // Get the PATH environment variable
320    let path_var = env::var_os("PATH").unwrap();
321
322    for path in env::split_paths(&path_var) {
323        let full_path = path.join(cmd);
324        if is_executable(&full_path) {
325            return true;
326        }
327    }
328    println!("Warning: Unable to find provided compiler as executable file on $PATH");
329    false
330}
331
332fn validate_compiler(comp: &str) -> bool {
333    // Attempt to provide some soft validation, warn the user if something
334    // looks fishy
335    if comp.contains(std::path::MAIN_SEPARATOR) {
336        // Treat it as a path
337        let Ok(path) = PathBuf::from(comp).canonicalize() else {
338            println!("Warning: Failed to canonicalize path \"{comp}\"",);
339            return false;
340        };
341        let exists = path.exists();
342        let is_file = path.is_file();
343        let is_exec = is_executable(&path);
344        if !exists {
345            println!(
346                "Warning: File does not exist at path \"{}\"",
347                path.display()
348            );
349        } else if !is_file {
350            println!(
351                "Warning: Path \"{}\" does not point to a file",
352                path.display()
353            );
354        } else if !is_exec {
355            println!(
356                "Warning: Path \"{}\" does not point to an executable file",
357                path.display()
358            );
359        }
360
361        exists && is_file && is_exec
362    } else {
363        is_executable_on_path(comp)
364    }
365}
366
367fn prompt_compiler() -> Option<String> {
368    if !Confirm::with_theme(&ColorfulTheme::default())
369        .with_prompt("Provide compiler to use with `compile_flags.txt` files or the following (optional) compile flags field")
370        .default(true)
371        .interact()
372        .unwrap() {
373        return None;
374    }
375    let mut comp: String;
376    loop {
377        comp = Input::with_theme(&ColorfulTheme::default())
378            .with_prompt("Enter Compiler")
379            .interact_text()
380            .unwrap();
381
382        // Attempt to provide some soft validation, warn the user if something
383        // looks fishy
384        if validate_compiler(&comp)
385            || !Confirm::with_theme(&ColorfulTheme::default())
386                .with_prompt("Re-enter compiler?")
387                .default(true)
388                .interact()
389                .unwrap()
390        {
391            break;
392        }
393    }
394
395    Some(comp)
396}
397
398fn prompt_config_opts() -> ConfigOptions {
399    let compiler = prompt_compiler();
400    // If the user specifies a compiler in their config, it makes sense to provide
401    // corresponding flags.
402    let compile_flags_txt = if compiler.is_some() {
403        let mut flags = Vec::new();
404        loop {
405            let flag: String = Input::with_theme(&ColorfulTheme::default())
406                .with_prompt("Add a compiler flag: (Enter an empty string to stop)")
407                .allow_empty(true)
408                .validate_with(|input: &String| -> Result<()> {
409                    // NOTE: Do we need to handle escaped quotes here?
410                    let mut in_quotes = false;
411                    for (i, c) in input.chars().enumerate() {
412                        match c {
413                            '\"' => in_quotes = !in_quotes,
414                            ' ' => {
415                                if !in_quotes {
416                                    return Err(anyhow!(
417                                        "\n{input}\n{}^\nUnquoted space found, specify each flag separately.",
418                                        " ".repeat(i),
419                                    ));
420                                }
421                            }
422                            _ => {}
423                        }
424                    }
425                    Ok(())
426                })
427                .interact_text()
428                .unwrap();
429            if flag.is_empty() {
430                break;
431            }
432            flags.push(flag);
433        }
434        Some(flags)
435    } else {
436        None
437    };
438
439    let diagnostics = Confirm::with_theme(&ColorfulTheme::default())
440        .with_prompt("Enable diagnostic features?")
441        .default(true)
442        .interact()
443        .unwrap();
444
445    // only offer the `default_diagnostics` option if both
446    //      A) diagnostics are enabled
447    //      B) The user didn't specify compiler instructions via the `compiler`
448    //         field (if they did so, default diagnostics will never be used)
449    let default_diagnostics = if diagnostics && compiler.is_none() && Confirm::with_theme(&ColorfulTheme::default())
450        .with_prompt("Attempt to provide diagnostics if no compilation information can be found for a source file?")
451        .default(true)
452        .interact()
453        .unwrap() {
454            Some(true)
455    } else {
456        Some(false)
457    };
458
459    ConfigOptions {
460        compiler,
461        compile_flags_txt,
462        diagnostics: Some(diagnostics),
463        default_diagnostics,
464    }
465}
466
467fn prompt_config() -> Config {
468    let instruction_set = prompt_arch();
469    let assembler = prompt_assembler();
470    let opts = if Confirm::with_theme(&ColorfulTheme::default())
471        .with_prompt("Configure diagnostic related features?")
472        .default(true)
473        .interact()
474        .unwrap()
475    {
476        Some(prompt_config_opts())
477    } else {
478        None
479    };
480
481    Config {
482        version: Some(env!("CARGO_PKG_VERSION").to_string()),
483        instruction_set,
484        assembler,
485        opts,
486    }
487}
488
489fn prompt_root_config(opts: &GenerateOpts) -> RootConfig {
490    let get_project_idx = |path: &PathBuf, projects: &Vec<ProjectConfig>| -> Option<usize> {
491        projects
492            .iter()
493            .enumerate()
494            .find(|(_, p)| p.path == *path)
495            .map(|(idx, _)| idx)
496    };
497    let default_config = if Confirm::with_theme(&ColorfulTheme::default())
498        .with_prompt("Create default config?")
499        .interact()
500        .unwrap()
501    {
502        Some(prompt_config())
503    } else {
504        None
505    };
506
507    let mut projects: Vec<ProjectConfig> = Vec::new();
508    loop {
509        if !Confirm::with_theme(&ColorfulTheme::default())
510            .with_prompt("Add a new project config?")
511            .interact()
512            .unwrap()
513        {
514            break;
515        }
516        let mut new_project = prompt_project(opts);
517        let mut check_action: Option<usize> = None;
518        for (i, project) in projects.iter().enumerate() {
519            if project.path == new_project.path {
520                eprintln!("Error: Multiple project configs with the same project path.");
521                println!(
522                    "Newer project config:\n{}",
523                    toml::to_string_pretty::<ProjectConfig>(&new_project)
524                        .expect("Failed to display project config")
525                );
526                println!(
527                    "Older project config ({i}):\n{}",
528                    toml::to_string_pretty::<ProjectConfig>(project)
529                        .expect("Failed to display project config")
530                );
531                let options = &[
532                    "Discard newer project config",
533                    "Edit newer project config path",
534                    "Discard older project config",
535                    "Edit older project config path",
536                ];
537                check_action = FuzzySelect::with_theme(&ColorfulTheme::default())
538                    .with_prompt("Choose resolution method")
539                    .default(0)
540                    .items(&options[..])
541                    .interact()
542                    .unwrap()
543                    .into();
544                break;
545            }
546        }
547        match check_action {
548            // Everything's good, add the new project
549            None => projects.push(new_project),
550            // Don't push `new_project` into `projects`
551            Some(0) => {}
552            // Prompt the user to change their new project path until one doesn't collide
553            Some(1) => {
554                while let Some(idx) = get_project_idx(&new_project.path, &projects) {
555                    println!(
556                        "Project path collision with project config {idx} -- {}",
557                        new_project.path.display()
558                    );
559                    new_project.path = prompt_project_path(opts);
560                }
561                projects.push(new_project);
562            }
563            // Remove the old project from `projects`
564            Some(2) => {
565                let old_idx = get_project_idx(&new_project.path, &projects).unwrap();
566                projects.remove(old_idx);
567                projects.push(new_project);
568            }
569            // Prompt the user to change their old project path until one doesn't collide
570            Some(3) => {
571                let old_idx = get_project_idx(&new_project.path, &projects).unwrap();
572                while let Some(idx) = get_project_idx(&new_project.path, &projects) {
573                    println!(
574                        "Project path collision with project config {idx} -- {}",
575                        new_project.path.display()
576                    );
577                    projects[old_idx].path = prompt_project_path(opts);
578                }
579                projects.push(new_project);
580            }
581            _ => unreachable!(),
582        }
583    }
584
585    RootConfig {
586        default_config,
587        projects: if projects.is_empty() {
588            None
589        } else {
590            Some(projects)
591        },
592    }
593}
594
595/// Prompts the user through generating a `.asm-lsp.toml` config file
596///
597/// # Errors
598///
599/// Returns `Err` if
600///     - A config file exists and the `--overwrite` flag wasn't used
601///     - An error occurs during config generation
602///     - Serialization of the generated config fails
603///     - Writing the serialized config to a file fails
604pub fn gen_config(opts: &GenerateOpts) -> Result<()> {
605    if !opts.overwrite && opts.output_path.exists() {
606        return Err(anyhow!(
607            "The target path \"{}\" already exists and `--overwrite` was not used",
608            opts.output_path.display()
609        ));
610    }
611    let root_config = prompt_root_config(opts);
612    let file_config = toml::to_string_pretty::<RootConfig>(&root_config).map_err(|e| {
613        anyhow!("Failed to serialize configuration -- {e}\nPlease file a bug report: https://github.com/bergercookie/asm-lsp/issues/new")
614    })?;
615    if !opts.quiet {
616        println!("{file_config}");
617    }
618    std::fs::write(&opts.output_path, file_config).map_err(|e| {
619        anyhow!(
620            "Failed to write config file to path \"{}\" -- {e}",
621            opts.output_path.display()
622        )
623    })?;
624    Ok(())
625}