///|
pub(all) struct HeaderField {
name : String
value : String
span : Span
} derive(Eq, Debug)
///|
pub(all) struct CatalogHeader {
fields : Array[HeaderField]
diagnostics : Array[Diagnostic]
} derive(Debug)
///|
pub fn CatalogHeader::get(self : CatalogHeader, name : String) -> String? {
let key = name.to_lower()
for field in self.fields {
if field.name.to_lower() == key {
return Some(field.value)
}
}
None
}
///|
/// Header spans point to the containing msgstr, not guessed decoded line positions.
pub fn read_header(catalog : Catalog) -> CatalogHeader {
let fields : Array[HeaderField] = []
let diagnostics : Array[Diagnostic] = []
let entry = match catalog.header() {
None => {
diagnostics.push(
issue(
"H001",
Warning,
"Catalog has no metadata header",
catalog.file,
line_span(1, 1, 0),
),
)
return { fields, diagnostics, }
}
Some(entry) => entry
}
let value = entry.translation(0).unwrap_or("")
let span = translated_span(entry, 0)
let seen : Map[String, Bool] = Map([])
for line in split_lines(value) {
if trim_text(line) == "" {
continue
}
let chars = line.to_array()
let mut colon = -1
for i, c in chars {
if c == ':' {
colon = i
break
}
}
if colon <= 0 {
diagnostics.push(
issue(
"H002",
Error,
"Header line requires a field name and colon: " + line,
catalog.file,
span,
),
)
continue
}
let name = trim_text(char_slice(chars, 0, colon))
let value = trim_text(char_slice(chars, colon + 1, chars.length()))
if !name
.to_array()
.all(c => (c.is_ascii_alphabetic() || c.is_ascii_digit()) || c == '-') {
diagnostics.push(
issue(
"H002",
Error,
"Invalid metadata field name: " + name,
catalog.file,
span,
),
)
continue
}
let key = name.to_lower()
if seen.get(key) is Some(_) {
diagnostics.push(
issue(
"H003",
Error,
"Duplicate metadata field: " + name,
catalog.file,
span,
),
)
}
seen.set(key, true)
fields.push({ name, value, span, })
}
{ fields, diagnostics, }
}
///|
fn header_span(catalog : Catalog) -> Span {
match catalog.header() {
None => line_span(1, 1, 0)
Some(entry) => translated_span(entry, 0)
}
}
///|
fn content_type_charset(content_type : String) -> Result[String, String] {
let fragments = content_type.split(";").map(s => s.to_owned()).to_array()
if trim_text(fragments[0]).to_lower() != "text/plain" {
return Err("Content-Type must be exactly text/plain")
}
let mut charset : String? = None
for i, fragment in fragments {
if i == 0 {
continue
}
let value = trim_text(fragment)
if value == "" {
continue
}
let parts = value.split("=").map(p => trim_text(p.to_owned())).to_array()
if parts.length() != 2 || parts[0] == "" || parts[1] == "" {
return Err("Malformed or unsupported Content-Type parameter: " + value)
}
if parts[0].to_lower() == "charset" {
if charset is Some(_) {
return Err("Duplicate charset parameter")
}
let raw = parts[1].to_array()
let decoded = if raw[0] == '"' || raw[raw.length() - 1] == '"' {
if raw.length() < 2 || raw[0] != '"' || raw[raw.length() - 1] != '"' {
return Err("Malformed quoted charset")
}
char_slice(raw, 1, raw.length() - 1)
} else {
parts[1]
}
if decoded.to_lower() != "utf-8" && decoded.to_lower() != "utf8" {
return Err("Unsupported catalog encoding: " + decoded)
}
charset = Some(decoded)
}
}
match charset {
Some(value) => Ok(value)
None => Err("Content-Type has no charset")
}
}
///|
fn plausible_language(value : String) -> Bool {
let chars = value.to_array()
if chars.is_empty() || !chars[0].is_ascii_alphabetic() {
return false
}
chars.all(c => {
(c.is_ascii_alphabetic() || c.is_ascii_digit()) ||
['_', '-', '@', '.'].contains(c)
})
}
///|
fn date_number(chars : Array[Char], start : Int32, end : Int32) -> Int32? {
if end > chars.length() {
return None
}
decimal_text(char_slice(chars, start, end), 9999)
}
///|
fn valid_po_date(value : String) -> Bool {
let chars = value.to_array()
if chars.length() != 21 ||
chars[4] != '-' ||
chars[7] != '-' ||
chars[10] != ' ' ||
chars[13] != ':' ||
(chars[16] != '+' && chars[16] != '-') {
return false
}
let year = date_number(chars, 0, 4).unwrap_or(-1)
let month = date_number(chars, 5, 7).unwrap_or(-1)
let day = date_number(chars, 8, 10).unwrap_or(-1)
let hour = date_number(chars, 11, 13).unwrap_or(-1)
let minute = date_number(chars, 14, 16).unwrap_or(-1)
let zone_hour = date_number(chars, 17, 19).unwrap_or(-1)
let zone_minute = date_number(chars, 19, 21).unwrap_or(-1)
if year < 1 ||
month < 1 ||
month > 12 ||
day < 1 ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59 ||
zone_hour < 0 ||
zone_hour > 23 ||
zone_minute < 0 ||
zone_minute > 59 {
return false
}
let days = match month {
4 | 6 | 9 | 11 => 30
2 =>
if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) {
29
} else {
28
}
_ => 31
}
day <= days
}
///|
pub fn validate_header(
catalog : Catalog,
header : CatalogHeader,
template~ : Bool = false,
) -> Array[Diagnostic] {
let result = header.diagnostics.copy()
if catalog.header() is None {
return result
}
let span = header_span(catalog)
for
name in [
"Project-Id-Version", "MIME-Version", "Content-Type", "Content-Transfer-Encoding",
] {
match header.get(name) {
None =>
result.push(
issue(
"H006",
Warning,
"Missing metadata field " + name,
catalog.file,
span,
),
)
Some(value) =>
if value == "" {
result.push(
issue(
"H006",
Warning,
"Metadata field is empty: " + name,
catalog.file,
span,
),
)
}
}
}
match header.get("Content-Type") {
None => ()
Some(value) =>
match content_type_charset(value) {
Err(message) =>
result.push(
issue(
"H004",
Error,
message,
catalog.file,
span,
hint="Use Content-Type: text/plain; charset=UTF-8.",
),
)
Ok(_) => ()
}
}
if header.get("MIME-Version") is Some(value) {
if value != "1.0" {
result.push(
issue("H005", Warning, "Expected MIME-Version 1.0", catalog.file, span),
)
}
}
if header.get("Content-Transfer-Encoding") is Some(value) {
if value.to_lower() != "8bit" {
result.push(
issue(
"H005",
Error,
"Only Content-Transfer-Encoding: 8bit is supported",
catalog.file,
span,
),
)
}
}
if !template {
match header.get("Language") {
None =>
result.push(
issue(
"H007",
Warning,
"Missing Language metadata",
catalog.file,
span,
),
)
Some(value) =>
if !plausible_language(value) {
result.push(
issue(
"H007",
Warning,
"Language metadata is empty or malformed",
catalog.file,
span,
),
)
}
}
}
for name in ["PO-Revision-Date", "POT-Creation-Date"] {
if header.get(name) is Some(value) {
if value != "" && !valid_po_date(value) {
result.push(
issue(
"H008",
Warning,
"Invalid " + name + ": expected YYYY-MM-DD HH:MM+ZZZZ",
catalog.file,
span,
),
)
}
}
}
for name in ["Project-Id-Version", "Last-Translator", "Language-Team"] {
if header.get(name) is Some(value) {
if value.contains("PACKAGE VERSION") ||
value.contains("FULL NAME") ||
value.contains("LANGUAGE <") {
result.push(
issue(
"H009",
Warning,
"Unfilled metadata placeholder in " + name,
catalog.file,
span,
),
)
}
}
}
result
}