// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 clbbbb
///|
pub struct SourceLicense {
path : String
expression : String
found : Bool
valid : Bool
message : String
} derive(Debug, Eq)
///|
pub struct SourceCopyright {
path : String
entries : Array[String]
found : Bool
valid : Bool
message : String
} derive(Debug, Eq)
///|
pub fn source_license(
path : String,
expression : String,
found : Bool,
valid : Bool,
message : String,
) -> SourceLicense {
{ path, expression, found, valid, message }
}
///|
pub fn extract_spdx_expression(source : String) -> String? {
match extract_spdx_expressions(source) {
[first, ..] => Some(first)
[] => None
}
}
///|
pub fn extract_spdx_expressions(source : String) -> Array[String] {
extract_spdx_tag_values(source, "SPDX-License-Identifier:")
}
///|
pub fn extract_spdx_copyright_texts(source : String) -> Array[String] {
extract_spdx_tag_values(source, "SPDX-FileCopyrightText:")
}
///|
pub fn extract_spdx_tag_values(
source : String,
marker : String,
) -> Array[String] {
let expressions : Array[String] = []
for line in split_char(source, '\n') {
match spdx_tag_value_in_comment(line, marker) {
Some(expression) => expressions.push(expression)
None => ()
}
}
expressions
}
///|
pub fn spdx_expression_in_comment(line : String) -> String? {
spdx_tag_value_in_comment(line, "SPDX-License-Identifier:")
}
///|
pub fn spdx_tag_value_in_comment(line : String, marker : String) -> String? {
match comment_text(line) {
Some(comment) =>
match comment.find(marker) {
Some(index) => {
let raw = comment.unsafe_substring(
start=index + marker.length(),
end=comment.length(),
)
Some(clean_comment_tail(raw))
}
None => None
}
None => None
}
}
///|
pub fn comment_text(line : String) -> String? {
let trimmed = trim_ascii(line)
if trimmed.has_prefix("//") {
Some(trimmed.unsafe_substring(start=2, end=trimmed.length()))
} else if trimmed.has_prefix("/*") {
Some(trimmed.unsafe_substring(start=2, end=trimmed.length()))
} else if trimmed.has_prefix("*") {
Some(trimmed.unsafe_substring(start=1, end=trimmed.length()))
} else {
None
}
}
///|
pub fn clean_comment_tail(text : String) -> String {
let trimmed = trim_ascii(text)
let without_block = text_replace(trimmed, "*/", "")
trim_ascii(without_block)
}
///|
pub fn source_copyright(
path : String,
entries : Array[String],
found : Bool,
valid : Bool,
message : String,
) -> SourceCopyright {
{ path, entries, found, valid, message }
}
///|
pub fn scan_copyright(path : String, source : String) -> SourceCopyright {
let entries = extract_spdx_copyright_texts(source)
if entries.is_empty() {
source_copyright(
path, entries, false, false, "missing SPDX-FileCopyrightText",
)
} else if has_empty_string(entries) {
source_copyright(path, entries, true, false, "empty SPDX-FileCopyrightText")
} else {
source_copyright(path, entries, true, true, "ok")
}
}
///|
pub fn has_empty_string(values : Array[String]) -> Bool {
for value in values {
if value == "" {
return true
}
}
false
}
///|
pub fn scan_copyrights(
paths : Array[String],
sources : Array[String],
) -> Array[SourceCopyright] {
let rows : Array[SourceCopyright] = []
let limit = min_int(paths.length(), sources.length())
for index = 0; index < limit; index = index + 1 {
rows.push(scan_copyright(paths[index], sources[index]))
}
rows
}
///|
pub fn copyright_report(path : String, source : String) -> String {
let item = scan_copyright(path, source)
if item.valid {
item.path + ": " + join_with(item.entries, "; ")
} else {
item.path + ": " + item.message
}
}
///|
pub fn missing_copyright_count(
paths : Array[String],
sources : Array[String],
) -> Int {
let mut count = 0
for item in scan_copyrights(paths, sources) {
if !item.found {
count = count + 1
}
}
count
}
///|
pub fn scan_source(path : String, source : String) -> SourceLicense {
let expressions = extract_spdx_expressions(source)
match expressions {
[first, ..] => {
let invalid = first_invalid_spdx_expression(expressions)
let conflicting = spdx_expressions_conflict(expressions)
let valid = invalid is None && !conflicting
source_license(
path,
normalize(first),
true,
valid,
if valid {
"ok"
} else if conflicting {
"conflicting SPDX-License-Identifier values"
} else {
validation_report(option_or(invalid, first))
},
)
}
[] =>
source_license(path, "", false, false, "missing SPDX-License-Identifier")
}
}
///|
pub fn first_invalid_spdx_expression(expressions : Array[String]) -> String? {
for expression in expressions {
if !is_valid(expression) {
return Some(expression)
}
}
None
}
///|
pub fn spdx_expressions_conflict(expressions : Array[String]) -> Bool {
let normalized : Array[String] = []
for expression in expressions {
if !is_valid(expression) {
return false
}
let value = normalize(expression)
if !contains_string(normalized, value) {
normalized.push(value)
}
}
normalized.length() > 1
}
///|
pub fn scan_report(path : String, source : String) -> String {
let item = scan_source(path, source)
if item.found && item.valid {
item.path + ": ok " + item.expression
} else {
item.path + ": " + item.message
}
}
///|
pub fn scan_sources(
paths : Array[String],
sources : Array[String],
) -> Array[SourceLicense] {
let result : Array[SourceLicense] = []
let limit = min_int(paths.length(), sources.length())
for index = 0; index < limit; index = index + 1 {
result.push(scan_source(paths[index], sources[index]))
}
result
}
///|
pub fn scan_sources_report(
paths : Array[String],
sources : Array[String],
) -> String {
let rows : Array[String] = []
for item in scan_sources(paths, sources) {
if item.found && item.valid {
rows.push(item.path + ": ok " + item.expression)
} else {
rows.push(item.path + ": " + item.message)
}
}
join_lines(rows)
}
///|
pub fn min_int(a : Int, b : Int) -> Int {
if a < b {
a
} else {
b
}
}
///|
pub fn missing_spdx_count(
paths : Array[String],
sources : Array[String],
) -> Int {
let mut count = 0
for item in scan_sources(paths, sources) {
if !item.found {
count = count + 1
}
}
count
}
///|
pub fn invalid_spdx_count(
paths : Array[String],
sources : Array[String],
) -> Int {
let mut count = 0
for item in scan_sources(paths, sources) {
if item.found && !item.valid {
count = count + 1
}
}
count
}