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 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 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 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
270fn is_executable(path: &Path) -> bool {
272 if path.is_file() {
273 #[cfg(unix)]
274 {
275 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 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 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#[must_use]
317fn is_executable_on_path(cmd: &str) -> bool {
318 use std::env;
319 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 if comp.contains(std::path::MAIN_SEPARATOR) {
336 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 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 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 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 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 None => projects.push(new_project),
550 Some(0) => {}
552 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 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 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
595pub 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}