use crate::error::ExtensionError;
use crate::validate::{
validate_excluded_platforms_str, validate_extension_name, validate_extension_version,
validate_spdx_license,
};
use super::model::DescriptionYml;
#[allow(clippy::too_many_lines)]
pub fn parse_description_yml(content: &str) -> Result<DescriptionYml, ExtensionError> {
let mut fields = Fields::default();
let mut maintainers: Vec<String> = Vec::new();
let mut section = Section::Other;
let mut in_maintainers = false;
let mut block: Option<BlockScalar> = None;
for line in content.lines() {
let trimmed = line.trim();
let indent = indent_of(line);
if let Some(open) = &mut block {
if trimmed.is_empty() || indent > open.key_indent {
open.lines.push(trimmed.to_string());
continue;
}
}
if let Some(finished) = block.take() {
let (key, value) = finished.into_pair();
fields.set(&key, value);
}
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if indent == 0 {
section = Section::of(trimmed);
in_maintainers = false;
continue;
}
if !matches!(section, Section::Extension | Section::Repo) {
continue;
}
if in_maintainers {
if let Some(item) = trimmed.strip_prefix('-') {
let name_val = strip_inline_comment(item.trim());
let name_val = unquote(name_val).unwrap_or(name_val);
if !name_val.is_empty() {
maintainers.push(name_val.to_string());
}
continue;
}
in_maintainers = false;
}
if let Some(open) = BlockScalar::opening(trimmed, indent) {
block = Some(open);
continue;
}
let keys: &[&str] = if section == Section::Repo {
&["github", "ref_next", "ref"]
} else {
&Fields::EXTENSION_KEYS
};
if let Some((key, value)) = keys
.iter()
.find_map(|key| parse_kv(trimmed, &format!("{key}:")).map(|v| (*key, v)))
{
fields.set(key, value.to_string());
} else if section == Section::Extension && trimmed == "maintainers:" {
in_maintainers = true;
}
}
if let Some(finished) = block {
let (key, value) = finished.into_pair();
fields.set(&key, value);
}
let Fields {
name,
description,
version,
language,
build,
license,
requires_toolchains,
excluded_platforms,
github,
git_ref,
git_ref_next,
} = fields;
if name.is_empty() {
return Err(ExtensionError::new(
"description.yml: missing required field 'extension.name'",
));
}
validate_extension_name(&name)
.map_err(|e| ExtensionError::new(format!("description.yml: extension.name: {e}")))?;
if description.is_empty() {
return Err(ExtensionError::new(
"description.yml: missing required field 'extension.description'",
));
}
if version.is_empty() {
return Err(ExtensionError::new(
"description.yml: missing required field 'extension.version'",
));
}
validate_extension_version(&version)
.map_err(|e| ExtensionError::new(format!("description.yml: extension.version: {e}")))?;
if language.is_empty() {
return Err(ExtensionError::new(
"description.yml: missing required field 'extension.language'",
));
}
if build.is_empty() {
return Err(ExtensionError::new(
"description.yml: missing required field 'extension.build'",
));
}
if license.is_empty() {
return Err(ExtensionError::new(
"description.yml: missing required field 'extension.license'",
));
}
validate_spdx_license(&license)
.map_err(|e| ExtensionError::new(format!("description.yml: extension.license: {e}")))?;
if !excluded_platforms.is_empty() {
validate_excluded_platforms_str(&excluded_platforms).map_err(|e| {
ExtensionError::new(format!(
"description.yml: extension.excluded_platforms: {e}"
))
})?;
}
if maintainers.is_empty() {
return Err(ExtensionError::new(
"description.yml: 'extension.maintainers' must list at least one maintainer",
));
}
if github.is_empty() {
return Err(ExtensionError::new(
"description.yml: missing required field 'repo.github'",
));
}
if !github.contains('/') {
return Err(ExtensionError::new(format!(
"description.yml: 'repo.github' must be in 'owner/repo' format, got '{github}'"
)));
}
if git_ref.is_empty() {
return Err(ExtensionError::new(
"description.yml: missing required field 'repo.ref'",
));
}
Ok(DescriptionYml {
name,
description,
version,
language,
build,
license,
requires_toolchains,
excluded_platforms,
maintainers,
github,
git_ref,
git_ref_next,
})
}
#[derive(Default)]
struct Fields {
name: String,
description: String,
version: String,
language: String,
build: String,
license: String,
requires_toolchains: String,
excluded_platforms: String,
github: String,
git_ref: String,
git_ref_next: String,
}
impl Fields {
const EXTENSION_KEYS: [&'static str; 8] = [
"name",
"description",
"version",
"language",
"build",
"license",
"requires_toolchains",
"excluded_platforms",
];
fn set(&mut self, key: &str, value: String) {
match key {
"name" => self.name = value,
"description" => self.description = value,
"version" => self.version = value,
"language" => self.language = value,
"build" => self.build = value,
"license" => self.license = value,
"requires_toolchains" => self.requires_toolchains = value,
"excluded_platforms" => self.excluded_platforms = value,
"github" => self.github = value,
"ref" => self.git_ref = value,
"ref_next" => self.git_ref_next = value,
_ => {}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Section {
Extension,
Repo,
Other,
}
impl Section {
fn of(line: &str) -> Self {
match line.split(':').next().map(str::trim) {
Some("extension") => Self::Extension,
Some("repo") => Self::Repo,
_ => Self::Other,
}
}
}
fn indent_of(line: &str) -> usize {
line.len() - line.trim_start().len()
}
struct BlockScalar {
key: String,
key_indent: usize,
literal: bool,
lines: Vec<String>,
}
impl BlockScalar {
fn opening(line: &str, indent: usize) -> Option<Self> {
let (key, value) = line.split_once(':')?;
let value = value.trim();
let rest = value.strip_prefix(['|', '>'])?;
if !rest.chars().all(|c| matches!(c, '-' | '+' | '0'..='9')) {
return None;
}
Some(Self {
key: key.trim().to_string(),
key_indent: indent,
literal: value.starts_with('|'),
lines: Vec::new(),
})
}
fn into_pair(self) -> (String, String) {
let separator = if self.literal { "\n" } else { " " };
(self.key, self.lines.join(separator).trim().to_string())
}
}
pub(super) fn parse_kv<'a>(line: &'a str, key: &str) -> Option<&'a str> {
line.strip_prefix(key).map(|v| {
let v = v.trim();
if let Some(inner) = unquote(v) {
return inner;
}
v.find(" #").map_or(v, |pos| v[..pos].trim_end())
})
}
pub(super) fn unquote(value: &str) -> Option<&str> {
let bytes = value.as_bytes();
if bytes.len() < 2 {
return None;
}
let first = *bytes.first()?;
if (first == b'"' || first == b'\'') && *bytes.last()? == first {
return value.get(1..value.len() - 1);
}
None
}
pub(super) fn strip_inline_comment(value: &str) -> &str {
value
.find(" #")
.map_or(value, |pos| value[..pos].trim_end())
}