Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 118 additions & 8 deletions flake-info/src/data/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,114 @@ impl From<import::License> for License {
fullName,
shortName,
url,
..
} => License {
url,
fullName: fullName.unwrap_or(shortName.unwrap_or("custom".into())),
},
// Compound variants should be flattened before reaching this point;
// if one slips through, use its SPDX expression as the display name.
other => License {
url: None,
fullName: other.spdx_expression(),
},
}
}
}

/// A tree representation of a compound license expression, preserving the
/// structural AND/OR/WITH/PLUS operators so the frontend can distinguish
/// licenses that are jointly required from licenses the user may choose
/// between. Leaves carry the displayable fullName and (optional) URL so they
/// can still render as links.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum LicenseExpression {
Leaf {
#[serde(rename = "fullName")]
full_name: String,
url: Option<String>,
},
And {
licenses: Vec<LicenseExpression>,
},
Or {
licenses: Vec<LicenseExpression>,
},
With {
license: Box<LicenseExpression>,
exception: Box<LicenseExpression>,
},
Plus {
license: Box<LicenseExpression>,
},
}

impl From<import::License> for LicenseExpression {
fn from(license: import::License) -> Self {
match license {
import::License::None { .. } => LicenseExpression::Leaf {
full_name: "no license specified".to_string(),
url: None,
},
import::License::Simple { license } => LicenseExpression::Leaf {
full_name: license,
url: None,
},
import::License::Full {
fullName,
shortName,
url,
..
} => LicenseExpression::Leaf {
full_name: fullName
.or(shortName)
.unwrap_or_else(|| "custom".to_string()),
url,
},
import::License::Compound {
operator, licenses, ..
} => {
let children: Vec<LicenseExpression> =
licenses.into_iter().map(|l| l.0.into()).collect();
if operator == "OR" {
LicenseExpression::Or { licenses: children }
} else {
LicenseExpression::And { licenses: children }
}
}
import::License::Exception {
license, exception, ..
} => LicenseExpression::With {
license: Box::new((*license).0.into()),
exception: Box::new((*exception).0.into()),
},
import::License::Plus { license, .. } => LicenseExpression::Plus {
license: Box::new((*license).0.into()),
},
}
}
}

/// Build an optional license-expression tree from a list of top-level licenses.
/// Returns `None` if no license in the list uses compound operators (the flat
/// `package_license` list is sufficient in that case). Multiple top-level
/// licenses are implicitly joined with AND, matching the historical nixpkgs
/// convention that a list of licenses means "all of them apply".
fn build_license_expression(
licenses: &[import::StringOrStruct<import::License>],
) -> Option<LicenseExpression> {
if !licenses.iter().any(|l| l.0.is_compound()) {
return None;
}
let items: Vec<LicenseExpression> = licenses.iter().map(|l| l.0.clone().into()).collect();
if items.len() == 1 {
items.into_iter().next()
} else {
Some(LicenseExpression::And { licenses: items })
}
}

// ----- Unified derivation representation

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
Expand All @@ -75,6 +175,7 @@ pub enum Derivation {
package_mainProgram: Option<String>,
package_license: Vec<License>,
package_license_set: Vec<String>,
package_license_expression: Option<LicenseExpression>,
package_maintainers: Vec<Maintainer>,
package_maintainers_set: Vec<String>,
package_teams: Vec<Team>,
Expand Down Expand Up @@ -164,15 +265,17 @@ impl TryFrom<(import::FlakeEntry, super::Flake)> for Derivation {

let long_description = long_description.map(|s| s.render_markdown()).transpose()?;

let package_license: Vec<License> = license
.map(OneOrMany::into_list)
.unwrap_or_default()
let license_list = license.map(OneOrMany::into_list).unwrap_or_default();

let package_license_expression = build_license_expression(&license_list);

let package_license: Vec<License> = license_list
.into_iter()
.map(|sos| sos.0.into())
.flat_map(|sos| sos.0.flatten())
.map(|l| l.into())
.collect();
let package_license_set: Vec<String> = package_license
.iter()
.clone()
.map(|l| l.fullName.to_owned())
.collect();

Expand All @@ -190,6 +293,7 @@ impl TryFrom<(import::FlakeEntry, super::Flake)> for Derivation {
package_mainProgram: None,
package_license,
package_license_set,
package_license_expression,
package_description: description.clone(),
package_maintainers: vec![maintainer.clone()],
package_maintainers_set: maintainer.name.map_or(vec![], |n| vec![n]),
Expand Down Expand Up @@ -256,12 +360,17 @@ impl TryFrom<import::NixpkgsEntry> for Derivation {
})
.into();

let package_license: Vec<License> = package
let license_list = package
.meta
.license
.map_or(Default::default(), OneOrMany::into_list)
.map_or(Default::default(), OneOrMany::into_list);

let package_license_expression = build_license_expression(&license_list);

let package_license: Vec<License> = license_list
.into_iter()
.map(|sos| sos.0.into())
.flat_map(|sos| sos.0.flatten())
.map(|l| l.into())
.collect();

let package_license_set = package_license
Expand Down Expand Up @@ -330,6 +439,7 @@ impl TryFrom<import::NixpkgsEntry> for Derivation {
package_mainProgram: package.meta.mainProgram,
package_license,
package_license_set,
package_license_expression,
package_maintainers,
package_maintainers_set,
package_teams,
Expand Down
131 changes: 130 additions & 1 deletion flake-info/src/data/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,21 @@ impl Platforms {
}
}

/// Different representations of the licence attribute
/// Different representations of the licence attribute.
///
/// Variant ordering matters for `#[serde(untagged)]`: serde tries each variant
/// top-to-bottom and picks the first one whose required fields are all present.
///
/// - `None` / `Simple` match degenerate cases (null, bare string via
/// `StringOrStruct`).
/// - `Compound` needs `licenses` (a Vec) -- unique among variants.
/// - `Exception` needs both `license` **and** `exception` -- must come before
/// `Plus` whose fields are a subset.
/// - `Plus` needs `license` + `operator` (but not `exception`).
/// - `Full` has only `Option` fields, so it acts as the catch-all for any
/// license object.
///
/// See NixOS/nixpkgs#468378 for the upstream compound-license proposal.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum License {
Expand All @@ -337,14 +351,129 @@ pub enum License {
Simple {
license: String,
},
/// `lib.licenses.AND [...]` / `lib.licenses.OR [...]`
#[allow(non_snake_case)]
Compound {
licenseType: String,
operator: String,
licenses: Vec<StringOrStruct<License>>,
},
/// `lib.licenses.WITH license exception`
#[allow(non_snake_case)]
Exception {
licenseType: String,
operator: String,
license: Box<StringOrStruct<License>>,
exception: Box<StringOrStruct<License>>,
},
/// `lib.licenses.PLUS license`
#[allow(non_snake_case)]
Plus {
licenseType: String,
operator: String,
license: Box<StringOrStruct<License>>,
},
#[allow(non_snake_case)]
Full {
fullName: Option<String>,
shortName: Option<String>,
spdxId: Option<String>,
url: Option<String>,
},
}

impl License {
/// Whether this license is a compound expression (AND, OR, WITH, PLUS)
/// rather than a simple/full leaf license.
pub fn is_compound(&self) -> bool {
matches!(
self,
License::Compound { .. } | License::Exception { .. } | License::Plus { .. }
)
}

/// Recursively collect all leaf (simple/full) licenses from a compound
/// expression. A leaf license returns itself.
pub fn flatten(self) -> Vec<License> {
match self {
License::Compound { licenses, .. } => {
licenses.into_iter().flat_map(|l| l.0.flatten()).collect()
}
License::Exception {
license, exception, ..
} => {
let mut out = license.0.flatten();
out.extend(exception.0.flatten());
out
}
License::Plus { license, .. } => license.0.flatten(),
other => vec![other],
}
}

/// Short display name suitable for use in SPDX-style expressions.
fn display_name(&self) -> String {
match self {
License::Full {
spdxId,
shortName,
fullName,
..
} => spdxId
.clone()
.or_else(|| shortName.clone())
.or_else(|| fullName.clone())
.unwrap_or_else(|| "custom".into()),
License::Simple { license } => license.clone(),
License::None { .. } => String::new(),
// Compound variants delegate to spdx_expression()
_ => self.spdx_expression(),
}
}

/// Build an SPDX-style expression string (e.g. "MIT AND (Apache-2.0 WITH
/// LLVM-exception)").
pub fn spdx_expression(&self) -> String {
match self {
License::Compound {
operator, licenses, ..
} => {
let parts: Vec<String> = licenses
.iter()
.map(|l| {
if l.0.is_compound() {
format!("({})", l.0.spdx_expression())
} else {
l.0.display_name()
}
})
.collect();
parts.join(&format!(" {} ", operator))
}
License::Exception {
license, exception, ..
} => {
let lic = if license.0.is_compound() {
format!("({})", license.0.spdx_expression())
} else {
license.0.display_name()
};
let exc = exception.0.display_name();
format!("{} WITH {}", lic, exc)
}
License::Plus { license, .. } => {
let lic = if license.0.is_compound() {
format!("({})", license.0.spdx_expression())
} else {
license.0.display_name()
};
format!("{}+", lic)
}
other => other.display_name(),
}
}
}

impl Default for License {
fn default() -> Self {
License::None { license: () }
Expand Down
1 change: 1 addition & 0 deletions flake-info/src/elastic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ lazy_static! {
"url": {"type": "text"}},
},
"package_license_set": {"type": "keyword"},
"package_license_expression": {"type": "object", "enabled": false},
"package_maintainers": {
"type": "nested",
"properties": {
Expand Down
2 changes: 1 addition & 1 deletion version.nix
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/**
Backend index version used by import jobs when writing data to Elasticsearch
*/
import = "46";
import = "47";

/**
Frontend index version used by the UI when querying Elasticsearch
Expand Down
Loading