///|
priv enum Field {
NoField
ContextField
IdField
PluralField
TranslationField(Int32)
}
///|
priv struct TranslationBuilder {
index : Int32
bytes : Array[Byte]
mut span : Span
}
///|
priv struct EntryBuilder {
mut context : Array[Byte]?
mut id : Array[Byte]?
mut plural : Array[Byte]?
translations : Array[TranslationBuilder]
comments : Array[Comment]
flags : Array[String]
references : Array[String]
mut obsolete : Bool
mut start : Position?
mut end : Position
mut id_span : Span?
mut plural_span : Span?
mut field : Field
}
///|
fn new_builder() -> EntryBuilder {
{
context: None,
id: None,
plural: None,
translations: [],
comments: [],
flags: [],
references: [],
obsolete: false,
start: None,
end: point(1, 1),
id_span: None,
plural_span: None,
field: NoField,
}
}
///|
fn hex_digit(c : Char) -> Int32? {
if c >= '0' && c <= '9' {
Some(c.to_int() - '0'.to_int())
} else if c >= 'a' && c <= 'f' {
Some(c.to_int() - 'a'.to_int() + 10)
} else if c >= 'A' && c <= 'F' {
Some(c.to_int() - 'A'.to_int() + 10)
} else {
None
}
}
///|
fn quoted_bytes(
chars : Array[Char],
from : Int32,
) -> Result[Array[Byte], String] {
let mut index = skip_spaces(chars, from)
if index >= chars.length() || chars[index] != '"' {
return Err("Expected a quoted string")
}
index += 1
let bytes : Array[Byte] = []
let mut closed = false
while index < chars.length() {
let c = chars[index]
index += 1
if c == '"' {
closed = true
break
}
if c != '\\' {
if c == '\u0000' || c == '\r' || c == '\n' {
return Err("Unescaped control character inside a PO string")
}
for byte in @utf8.encode(c.to_string()[:]) {
bytes.push(byte)
}
continue
}
if index >= chars.length() {
return Err("A backslash at the end of a quoted string is incomplete")
}
let escaped = chars[index]
index += 1
let value = match escaped {
'n' => 10
'r' => 13
't' => 9
'b' => 8
'f' => 12
'v' => 11
'a' => 7
'"' => 34
'\\' => 92
'0'..<='7' => {
let mut number = escaped.to_int() - '0'.to_int()
let mut count = 1
while count < 3 &&
index < chars.length() &&
chars[index] >= '0' &&
chars[index] <= '7' {
number = number * 8 + chars[index].to_int() - '0'.to_int()
index += 1
count += 1
}
if number > 255 {
return Err("Octal escape is outside the byte range")
}
number
}
'x' => {
let mut number = 0
let mut count = 0
while index < chars.length() {
match hex_digit(chars[index]) {
None => break
Some(digit) => {
if number > (255 - digit) / 16 {
return Err("Hex escape is outside the byte range")
}
number = number * 16 + digit
index += 1
count += 1
}
}
}
if count == 0 {
return Err("Hex escape requires at least one digit")
}
number
}
_ => return Err("Unsupported PO escape: \\" + escaped.to_string())
}
bytes.push(value.to_byte())
}
if !closed {
return Err("Quoted string has no closing quote")
}
if skip_spaces(chars, index) != chars.length() {
return Err("Unexpected content after the closing quote")
}
Ok(bytes)
}
///|
fn decode_field(
bytes : Array[Byte],
file : String,
span : Span,
diagnostics : Array[Diagnostic],
) -> String {
@utf8.decode(Bytes::from_array(bytes)[:]) catch {
_ => {
diagnostics.push(
issue(
"P008",
Error,
"Quoted byte escapes do not form valid UTF-8",
file,
span,
hint="This release accepts UTF-8 catalogs only.",
),
)
""
}
}
}
///|
fn finish_builder(
builder : EntryBuilder,
file : String,
diagnostics : Array[Diagnostic],
) -> Entry? {
let start = builder.start.unwrap_or(point(1, 1))
let span = { start, end: builder.end, }
let id_span = builder.id_span.unwrap_or(span)
let id = match builder.id {
None => {
if builder.context is Some(_) ||
builder.plural is Some(_) ||
!builder.translations.is_empty() {
diagnostics.push(
issue("P003", Error, "Entry has fields but no msgid", file, span),
)
}
if !builder.comments.is_empty() &&
builder.context is None &&
builder.plural is None &&
builder.translations.is_empty() {
diagnostics.push(
issue(
"P012",
Warning,
"Trailing comments are not attached to a catalog entry",
file,
span,
hint="Normalization refuses to discard these comments.",
),
)
}
return None
}
Some(bytes) => decode_field(bytes, file, id_span, diagnostics)
}
let context = builder.context.map(bytes => {
decode_field(bytes, file, span, diagnostics)
})
let plural = builder.plural.map(bytes => {
decode_field(bytes, file, builder.plural_span.unwrap_or(span), diagnostics)
})
let translations = builder.translations.map(t => {
index: t.index,
value: decode_field(t.bytes, file, t.span, diagnostics),
span: t.span,
})
Some({
context,
id,
plural,
translations,
flags: unique_strings(builder.flags),
comments: builder.comments.copy(),
references: unique_strings(builder.references),
obsolete: builder.obsolete,
span,
id_span,
plural_span: builder.plural_span,
})
}
///|
fn add_comment(builder : EntryBuilder, text : String, span : Span) -> Unit {
let chars = text.to_array()
let marker = if chars.length() > 1 { chars[1] } else { ' ' }
let (kind, content) = match marker {
',' => ("flags", trim_text(char_slice(chars, 2, chars.length())))
':' => ("reference", trim_text(char_slice(chars, 2, chars.length())))
'.' => ("extracted", trim_text(char_slice(chars, 2, chars.length())))
'|' => ("previous", trim_text(char_slice(chars, 2, chars.length())))
_ => ("translator", trim_text(char_slice(chars, 1, chars.length())))
}
builder.comments.push({ kind, text: content, span, })
if kind == "flags" {
for flag in content.split(",") {
let value = flag.trim().to_owned()
if value != "" {
builder.flags.push(value)
}
}
}
if kind == "reference" {
let tokens = content.to_array()
let mut start = 0
for index, c in tokens {
if c.is_whitespace() {
if index > start {
builder.references.push(char_slice(tokens, start, index))
}
start = index + 1
}
}
if start < tokens.length() {
builder.references.push(char_slice(tokens, start, tokens.length()))
}
}
}
///|
fn append_field(
builder : EntryBuilder,
bytes : Array[Byte],
span : Span,
) -> Bool {
match builder.field {
NoField => return false
ContextField =>
builder.context
.iter()
.each(target => bytes.each(byte => target.push(byte)))
IdField => {
builder.id.iter().each(target => bytes.each(byte => target.push(byte)))
if builder.id_span is Some(previous) {
builder.id_span = Some({ start: previous.start, end: span.end, })
}
}
PluralField => {
builder.plural
.iter()
.each(target => bytes.each(byte => target.push(byte)))
if builder.plural_span is Some(previous) {
builder.plural_span = Some({ start: previous.start, end: span.end, })
}
}
TranslationField(index) =>
for t in builder.translations {
if t.index == index {
bytes.each(byte => t.bytes.push(byte))
t.span = { start: t.span.start, end: span.end, }
}
}
}
true
}
///|
/// Parse a catalog without throwing. Malformed entries produce diagnostics.
pub fn parse_catalog(
text : String,
file~ : String = "",
options~ : Options = default_options(),
) -> Catalog {
let lines = split_lines(text)
let entries : Array[Entry] = []
let diagnostics : Array[Diagnostic] = []
let mut builder = new_builder()
for line_index, source in lines {
let line_number = line_index + 1
let mut chars = source.to_array()
if line_index == 0 && !chars.is_empty() && chars[0] == '\uFEFF' {
chars = chars[1:].to_owned()
}
if chars.length() > options.max_line_chars {
diagnostics.push(
issue(
"P009",
Error,
"Line exceeds the configured character limit",
file,
line_span(line_number, 1, 1),
),
)
break
}
let mut offset = skip_spaces(chars, 0)
if offset == chars.length() {
if builder.id is None &&
builder.context is None &&
builder.plural is None &&
builder.translations.is_empty() {
continue
}
if finish_builder(builder, file, diagnostics) is Some(entry) {
entries.push(entry)
}
builder = new_builder()
continue
}
let mut obsolete = false
if chars.length() >= offset + 2 &&
chars[offset] == '#' &&
chars[offset + 1] == '~' {
obsolete = true
offset = skip_spaces(chars, offset + 2)
}
let content = char_slice(chars, offset, chars.length())
let span = line_span(line_number, offset + 1, chars.length() - offset)
if content.has_prefix("#") {
if !builder.translations.is_empty() {
if finish_builder(builder, file, diagnostics) is Some(entry) {
entries.push(entry)
}
builder = new_builder()
}
add_comment(builder, content, span)
if builder.start is None {
builder.start = Some(span.start)
}
builder.obsolete = obsolete
continue
}
if content == "" {
continue
}
let data = content.to_array()
if data[0] == '"' {
if builder.obsolete != obsolete {
diagnostics.push(
issue(
"P006",
Error,
"Continuation changes active/obsolete status",
file,
span,
),
)
continue
}
match quoted_bytes(data, 0) {
Err(message) =>
diagnostics.push(issue("P002", Error, message, file, span))
Ok(bytes) =>
if !append_field(builder, bytes, span) {
diagnostics.push(
issue(
"P004",
Error,
"String continuation has no preceding field",
file,
span,
),
)
}
}
builder.end = span.end
continue
}
let mut word_end = 0
while word_end < data.length() &&
(data[word_end].is_ascii_alphabetic() || data[word_end] == '_') {
word_end += 1
}
let word = char_slice(data, 0, word_end)
let starts_entry = (word == "msgid" && builder.id is Some(_)) ||
(word == "msgctxt" && builder.id is Some(_))
if starts_entry {
if finish_builder(builder, file, diagnostics) is Some(entry) {
entries.push(entry)
}
builder = new_builder()
}
if entries.length() >= options.max_entries {
diagnostics.push(
issue(
"P009",
Error,
"Catalog exceeds the configured entry limit",
file,
span,
),
)
break
}
if builder.start is None {
builder.start = Some(span.start)
}
if builder.id is Some(_) && builder.obsolete != obsolete {
diagnostics.push(
issue(
"P006",
Error,
"Entry mixes active and obsolete fields",
file,
span,
),
)
}
builder.obsolete = obsolete
builder.end = span.end
let mut value_start = word_end
let indexed = word == "msgstr" &&
value_start < data.length() &&
data[value_start] == '['
let field = match word {
"msgctxt" => ContextField
"msgid" => IdField
"msgid_plural" => PluralField
"msgstr" =>
if value_start < data.length() && data[value_start] == '[' {
value_start += 1
let index_start = value_start
while value_start < data.length() &&
data[value_start].is_ascii_digit() {
value_start += 1
}
if value_start >= data.length() || data[value_start] != ']' {
diagnostics.push(
issue(
"P005",
Error,
"msgstr index requires digits and a closing bracket",
file,
span,
),
)
builder.field = NoField
continue
}
let parsed = decimal_text(
char_slice(data, index_start, value_start),
10000,
)
value_start += 1
match parsed {
Some(index) => TranslationField(index)
None => {
diagnostics.push(
issue(
"P005",
Error,
"msgstr index is invalid or exceeds 10000",
file,
span,
),
)
continue
}
}
} else {
TranslationField(0)
}
_ => {
diagnostics.push(
issue("P001", Error, "Unknown PO directive: " + word, file, span),
)
builder.field = NoField
continue
}
}
if value_start < data.length() &&
data[value_start] != ' ' &&
data[value_start] != '\t' {
diagnostics.push(
issue(
"P001",
Error,
"A directive must be followed by whitespace",
file,
span,
),
)
continue
}
let bytes = match quoted_bytes(data, value_start) {
Ok(bytes) => bytes
Err(message) => {
diagnostics.push(issue("P002", Error, message, file, span))
builder.field = NoField
continue
}
}
let mut duplicate = false
match field {
ContextField => {
duplicate = builder.context is Some(_)
builder.context = Some(bytes)
}
IdField => {
builder.id = Some(bytes)
builder.id_span = Some(span)
}
PluralField => {
if builder.id is None || !builder.translations.is_empty() {
diagnostics.push(
issue(
"P007",
Error,
"msgid_plural must follow msgid and precede msgstr",
file,
span,
),
)
}
duplicate = builder.plural is Some(_)
builder.plural = Some(bytes)
builder.plural_span = Some(span)
}
TranslationField(index) => {
if (builder.plural is None && indexed) ||
(builder.plural is Some(_) && !indexed) {
diagnostics.push(
issue(
"P011",
Error,
"Use msgstr for singular entries and msgstr[index] for plural entries",
file,
span,
),
)
}
duplicate = builder.translations.any(t => t.index == index)
if !duplicate {
builder.translations.push({ index, bytes, span, })
}
if builder.id is None {
diagnostics.push(
issue("P007", Error, "msgstr must follow msgid", file, span),
)
}
}
NoField => ()
}
if duplicate {
diagnostics.push(
issue("P010", Error, "Duplicate field inside an entry", file, span),
)
builder.field = NoField
} else {
builder.field = field
}
}
if finish_builder(builder, file, diagnostics) is Some(entry) {
if entries.length() < options.max_entries {
entries.push(entry)
}
}
{ file, entries, diagnostics, }
}