lsp-cli 0.1.0

Command-line tool for talking to Language Server Protocol (LSP) servers from the terminal.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum SourceId {
    Npm {
        package_name: String,
        version: String,
    },
    Pypi {
        package_name: String,
        version: String,
        extras: Vec<String>,
    },
    Cargo {
        package_name: String,
        version: String,
    },
    Golang {
        module_path: String,
        version: String,
    },
    Github {
        repository: String,
        version: String,
    },
    Generic {
        package_name: String,
        version: String,
    },
    Unsupported {
        kind: String,
    },
}

pub(crate) fn parse_source_id(source_id: &str) -> Result<SourceId, String> {
    let without_prefix = source_id
        .strip_prefix("pkg:")
        .ok_or_else(|| format!("unsupported Mason package source {source_id}"))?;
    let (package_ref, version_with_qualifiers) = without_prefix
        .rsplit_once('@')
        .ok_or_else(|| format!("unsupported Mason package source {source_id}"))?;
    let (kind, name) = package_ref
        .split_once('/')
        .ok_or_else(|| format!("unsupported Mason package source {source_id}"))?;
    let decoded_name = percent_decode_component(name)
        .ok_or_else(|| format!("unsupported Mason package source {source_id}"))?;

    let (version, qualifiers) = split_version_qualifiers(version_with_qualifiers);

    Ok(match kind {
        "npm" => SourceId::Npm {
            package_name: decoded_name.clone(),
            version: version.to_string(),
        },
        "pypi" => SourceId::Pypi {
            package_name: decoded_name.clone(),
            version: version.to_string(),
            extras: parse_pypi_extras(qualifiers),
        },
        "cargo" => SourceId::Cargo {
            package_name: decoded_name.clone(),
            version: version.to_string(),
        },
        "golang" => SourceId::Golang {
            module_path: decoded_name.clone(),
            version: version.to_string(),
        },
        "github" => SourceId::Github {
            repository: decoded_name.clone(),
            version: version.to_string(),
        },
        "generic" => SourceId::Generic {
            package_name: decoded_name,
            version: version.to_string(),
        },
        _ => SourceId::Unsupported {
            kind: kind.to_string(),
        },
    })
}

fn split_version_qualifiers(version_with_qualifiers: &str) -> (&str, Option<&str>) {
    match version_with_qualifiers.split_once('?') {
        Some((version, qualifiers)) => (version, Some(qualifiers)),
        None => (version_with_qualifiers, None),
    }
}

fn parse_pypi_extras(qualifiers: Option<&str>) -> Vec<String> {
    qualifiers
        .into_iter()
        .flat_map(|qualifiers| url::form_urlencoded::parse(qualifiers.as_bytes()))
        .filter_map(|(key, value)| (key == "extra").then(|| value.into_owned()))
        .collect()
}

fn percent_decode_component(value: &str) -> Option<String> {
    let bytes = value.as_bytes();
    let mut decoded = String::with_capacity(value.len());
    let mut index = 0;

    while index < bytes.len() {
        if bytes[index] == b'%' {
            if index + 2 >= bytes.len() {
                return None;
            }
            let high = decode_hex_digit(bytes[index + 1])?;
            let low = decode_hex_digit(bytes[index + 2])?;
            decoded.push((high << 4 | low) as char);
            index += 3;
            continue;
        }

        decoded.push(bytes[index] as char);
        index += 1;
    }

    Some(decoded)
}

fn decode_hex_digit(byte: u8) -> Option<u8> {
    match byte {
        b'0'..=b'9' => Some(byte - b'0'),
        b'a'..=b'f' => Some(byte - b'a' + 10),
        b'A'..=b'F' => Some(byte - b'A' + 10),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::{SourceId, parse_source_id};

    #[test]
    fn parses_supported_source_ids() {
        assert_eq!(
            parse_source_id("pkg:npm/pyright@1.1.409").expect("npm source should parse"),
            SourceId::Npm {
                package_name: "pyright".to_string(),
                version: "1.1.409".to_string(),
            }
        );
        assert_eq!(
            parse_source_id("pkg:npm/%40angular/language-server@21.2.11")
                .expect("scoped npm source should parse"),
            SourceId::Npm {
                package_name: "@angular/language-server".to_string(),
                version: "21.2.11".to_string(),
            }
        );
        assert_eq!(
            parse_source_id("pkg:pypi/jedi-language-server@0.46.0")
                .expect("pypi source should parse"),
            SourceId::Pypi {
                package_name: "jedi-language-server".to_string(),
                version: "0.46.0".to_string(),
                extras: Vec::new(),
            }
        );
        assert_eq!(
            parse_source_id("pkg:pypi/python-lsp-server@1.14.0?extra=all")
                .expect("pypi source with extras should parse"),
            SourceId::Pypi {
                package_name: "python-lsp-server".to_string(),
                version: "1.14.0".to_string(),
                extras: vec!["all".to_string()],
            }
        );
        assert_eq!(
            parse_source_id("pkg:cargo/asm-lsp@0.10.1").expect("cargo source should parse"),
            SourceId::Cargo {
                package_name: "asm-lsp".to_string(),
                version: "0.10.1".to_string(),
            }
        );
        assert_eq!(
            parse_source_id("pkg:golang/golang.org/x/tools/gopls@v0.21.1")
                .expect("golang source should parse"),
            SourceId::Golang {
                module_path: "golang.org/x/tools/gopls".to_string(),
                version: "v0.21.1".to_string(),
            }
        );
    }

    #[test]
    fn preserves_unsupported_source_kind() {
        assert_eq!(
            parse_source_id("pkg:gem/solargraph@0.50.0").expect("gem source should parse"),
            SourceId::Unsupported {
                kind: "gem".to_string(),
            }
        );
    }
}