nu-completion-script 0.1.3

A script for generating nu completions from Fish completions
use std::{
    collections::HashSet,
    fmt::Display,
    fs::File,
    io::BufRead,
    io::{self, BufReader, Seek, Write},
    path::{Path, PathBuf},
    sync::{LazyLock, RwLock},
};

use anyhow::Result;
use log::{as_debug, as_serde, debug, error, info, trace, warn};

use crate::{completions, config::Config, dir_walker::walk_dir};

pub(crate) fn processing_failed(path: impl AsRef<Path>, err: anyhow::Error) -> Result<!> {
    error!(
        "failed to process completions at {:?}: {err:?}",
        path.as_ref()
    );
    Err(err)
}

#[derive(Debug, Default)]
pub(crate) struct CompletionsProcessor {
    definition_files: RwLock<HashSet<PathBuf>>,
}

impl CompletionsProcessor {
    pub(crate) fn process_file_or_dir(&self, path: PathBuf) -> Result<()> {
        self.process_file_or_dir_given_output_dir(path, Config::output_dir())
    }

    pub(crate) fn process_file_or_dir_given_output_dir(
        &self,
        path: PathBuf,
        output_dir: impl AsRef<Path>,
    ) -> Result<()> {
        info!(file = path.to_string_lossy(); "processing file or directory");
        let output_dir = output_dir.as_ref();
        walk_dir(&path, (), |path, _| {
            self.process_file_given_output_dir(&path, output_dir)
                .map(|_| ())
        })
    }

    pub(crate) fn process_file_given_output_dir(
        &self,
        path: &Path,
        output_dir: &Path,
    ) -> Result<PathBuf> {
        info!(file = path.to_string_lossy(); "processing file");
        if !path.is_file() {
            return Err(io::Error::new(
                io::ErrorKind::Unsupported,
                format!("{path:#?} is not a file"),
            )
            .into());
        }
        let errmsg = format!("reading file {path:#?}");
        let file = BufReader::new(File::open(path)?);
        trace!(file = as_debug!(path); "opened file for processing");
        let completions =
            completions::Completions::parse(file.lines().map(|line| line.expect(&errmsg)))?;
        trace!("successfully parsed completions for {path:?}");
        let location = output_dir.join(
            path.with_extension("nu")
                .file_name()
                .expect("directory already checked for"),
        );
        debug!("writing completions parsed from {path:?} into {location:?}");
        Completions::at(&location)?.output(completions)?;
        self.definition_files
            .write()
            .expect("rwlock write access")
            .insert(location.clone());
        Ok(location)
    }

    pub(crate) fn write_sourcing_file(&self, to: &Path) -> Result<()> {
        let mut file = File::create(to)?;
        for def in self
            .definition_files
            .read()
            .expect("rwlock read access")
            .iter()
        {
            file.write_all(format!("source {def:?}\n").as_bytes())?;
        }
        Ok(())
    }
}
#[derive(Debug)]
pub(crate) struct Completions<IO: Seek + Write> {
    io: IO,
    indent: usize,
}

impl<IO: Seek + Write> Completions<IO> {
    fn new(io: IO) -> Self {
        Self { io, indent: 0 }
    }
}

impl Completions<File> {
    fn at(location: impl AsRef<Path>) -> Result<Self> {
        let file = File::create(location)?;
        Ok(Self::new(file))
    }
}

struct Synonym<'a> {
    synonym_of: String,
    name: String,
    description: Option<&'a str>,
}

impl<IO: Seek + Write> Completions<IO> {
    fn write(&mut self, it: impl Display + log::kv::ToValue) -> Result<&mut Self> {
        trace!(content=it, indent=self.indent; "writing data");
        write!(self.io, "{}{it}", self.indent_str())?;
        Ok(self)
    }

    fn eol(&mut self) -> Result<&mut Self> {
        writeln!(self.io)?;
        Ok(self)
    }

    pub(crate) fn output(&mut self, completions: completions::Completions) -> Result<()> {
        let mut command_count: usize = 0;
        for (cmd, opts) in completions.read().expect("rwlock read access").iter() {
            let cmd = if let Err(which::Error::CannotCanonicalize) = which::which(cmd) {
                cmd.replace('-', " ")
            } else {
                cmd.to_string()
            };
            let cmd = cmd.as_str();
            self.write(format!(r#"export extern "{cmd}" ["#))?.eol()?;
            self.indent += 1;
            let mut rules: usize = 0;
            let mut synonyms = vec![];
            for option in opts {
                let (mut def, mut arg) = (String::new(), String::new());
                match &option.old_option.as_slice() {
                    [] => {
                        if option.long.is_empty() {
                            match &option.short.as_slice() {
                                &[] => (),
                                [opt] => {
                                    def.push('-');
                                    def.push_str(opt);
                                }
                                options => {
                                    def.push('-');
                                    def.push_str(&options[0]);
                                    for opt in &options[1..] {
                                        synonyms.push(Synonym {
                                            name: format!("-{opt}"),
                                            synonym_of: format!("--{}", &options[0]),
                                            description: option.description.as_deref(),
                                        });
                                    }
                                }
                            }
                        } else {
                            let opt = option.long[0].as_ref();
                            def.push_str("--");
                            def.push_str(opt);
                            if !option.short.is_empty() {
                                def.push_str("(-");
                                def.push_str(&option.short[0]);
                                def.push(')');
                                for opt in &option.short[1..] {
                                    synonyms.push(Synonym {
                                        name: format!("-{opt}"),
                                        synonym_of: format!("--{}", &option.long[0]),
                                        description: option.description.as_deref(),
                                    });
                                }
                            }

                            for opt in &option.long[1..] {
                                synonyms.push(Synonym {
                                    name: format!("--{opt}"),
                                    synonym_of: format!("--{}", &option.long[0]),
                                    description: option.description.as_deref(),
                                });
                            }
                        }
                    }

                    [opt] => {
                        warn!(opt = opt; "skipping old-style long option");
                        continue;
                        // def.push('-');
                        // def.push_str(opt);
                    }
                    options => {
                        warn!(options = as_serde!(options); "skipping old-style long options");
                        continue;
                        // def.push('-');
                        // def.push_str(&options[0]);
                        // for opt in &options[1..] {
                        //     synonyms.push(Synonym {
                        //         name: format!("-{opt}"),
                        //         synonym_of: format!("-{}", &options[0]),
                        //         description: option.description.as_ref().map(|it| it.as_str()),
                        //     });
                        // }
                    }
                }
                if def.is_empty() {
                    warn!(option = as_debug!(option), cmd = cmd; "no option or arg");
                    continue;
                }
                if option.argument.is_some() {
                    arg.push_str(": string");
                }
                let (def, arg) = (def.as_str(), arg.as_str());
                debug!(def=def, arg=arg, cmd=cmd; "writing command to file");
                self.write(def.to_owned() + arg)?;
                if let Some(description) = &option.description {
                    let description = description.as_str();
                    debug!(def=def, description=description; "writing description");
                    self.write("  # ".to_owned() + description)?.eol()?;
                } else {
                    self.eol()?;
                }
                rules += 1;
            }
            for Synonym {
                synonym_of,
                name,
                description,
            } in &synonyms
            {
                debug!(cmd = cmd, opt = name; "writing synonym");
                let desc = if let Some(desc) = description {
                    format!("{desc} (synonym of {synonym_of})")
                } else {
                    format!("synonym of {synonym_of}")
                };
                self.write(format!("{name} #  {desc}"))?.eol()?;
                rules += 1;
            }
            debug!(rule_count=rules, cmd=cmd; "wrote rules");
            self.indent -= 1;
            self.write("]\n")?;
            command_count += 1;
        }
        debug!(command_count=command_count; "wrote commands");
        Ok(())
    }

    fn indent_str(&self) -> String {
        let mut cache = INDENT_CACHE.write().expect("poisoned mutex");
        if let Some(i) = cache.get(self.indent) {
            trace!(level=self.indent, str=i.as_str(); "got cached indent");
            i.clone()
        } else {
            let max_cached = cache.len();
            trace!(max_cached=max_cached, target_indent=self.indent; "indent not yet cached, filling");
            for i in max_cached..=self.indent {
                let text: String = " ".repeat(i * 4);
                trace!(level=i, str=text.as_str(); "caching indent level");
                cache.push(text);
            }
            // Size is ensured above
            unsafe { cache.get_unchecked(self.indent).clone() }
        }
    }
}

static INDENT_CACHE: LazyLock<RwLock<Vec<String>>> = LazyLock::new(|| RwLock::new(vec![]));