///| Expand only variables defined earlier, as pkg-config does.
///|
/// The map can include caller-provided builtins such as pcfiledir.
pub fn expand(
value : String,
variables : Map[String, String],
location~ : Location,
) -> Result[String, Diagnostic] {
expand_limited(
value, variables, location, 1048576, "Expanded value exceeds 1 Mi UTF-16 code units",
)
}
///|
fn expand_limited(
value : String,
variables : Map[String, String],
location : Location,
limit : Int32,
limit_message : String,
) -> Result[String, Diagnostic] {
let chars = value.to_array()
let out = StringBuilder()
let mut expanded_length = 0
let mut i = 0
while i < chars.length() {
if chars[i] == '$' && i + 1 < chars.length() && chars[i + 1] == '$' {
if expanded_length == limit {
return Err(diag("PC009", limit_message, location))
}
out.write_char('$')
expanded_length = expanded_length + 1
i = i + 2
} else if chars[i] == '$' && i + 1 < chars.length() && chars[i + 1] == '{' {
let start = i
i = i + 2
let name_start = i
while i < chars.length() && chars[i] != '}' {
i = i + 1
}
let at = { ..location, column: location.column + start, }
if i == chars.length() {
return Err(diag("PC003", "Unclosed variable reference", at))
}
let name = chars_text(chars, name_start, i)
match variables.get(name) {
Some(v) => {
if v.length() > limit - expanded_length {
return Err(diag("PC009", limit_message, location))
}
expanded_length = expanded_length + v.length()
out.write_string(v)
}
None => return Err(diag("PC004", "Undefined variable: " + name, at))
}
i = i + 1
} else {
let width = if chars[i].to_int() > 65535 { 2 } else { 1 }
if width > limit - expanded_length {
return Err(diag("PC009", limit_message, location))
}
out.write_char(chars[i])
expanded_length = expanded_length + width
i = i + 1
}
}
Ok(out.to_string())
}
///|
// String lengths and both expansion limits count UTF-16 code units, not bytes.
priv struct ExpansionBudget {
mut remaining : Int32
mut exhausted : Bool
}
///|
fn valid_key(name : String) -> Bool {
!name.is_empty() &&
name
.iter()
.all(c => {
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '_' ||
c == '.' ||
c == '-'
})
}
///| Parse .pc syntax with ordered substitution, comments and continuations.
///|
/// Deliberately strict about duplicate definitions and undefined variables.
pub fn parse(
content : String,
source~ : String = "input.pc",
pcfiledir~ : String = ".",
overrides~ : Map[String, String] = Map([]),
) -> Document {
let entries : Array[Entry] = []
let diagnostics : Array[Diagnostic] = []
let budget = { remaining: 1048576, exhausted: false, }
let variables : Map[String, String] = { "pcfiledir": pcfiledir }
for name, value in overrides {
if name != "pcfiledir" {
variables[name] = value
}
}
let defined_variables : Map[String, Bool] = Map([])
let fields : Map[String, Bool] = Map([])
let chars = content.to_array()
let line = StringBuilder()
let mut physical_line = 1
let mut start_line = 1
let mut i = if chars.length() > 0 && chars[0] == '\u{FEFF}' { 1 } else { 0 }
while i < chars.length() {
let c = chars[i]
if c == '\\' && i + 1 < chars.length() {
let next = chars[i + 1]
if next == '\n' ||
(next == '\r' && i + 2 < chars.length() && chars[i + 2] == '\n') {
physical_line = physical_line + 1
i = i + (if next == '\r' { 3 } else { 2 })
continue
}
// Escaped hashes are literal, other escapes belong to the fragment parser.
if next == '#' {
line.write_char('#')
} else {
line.write_char(c)
line.write_char(next)
}
i = i + 2
continue
}
if c == '#' {
while i < chars.length() && chars[i] != '\n' {
i = i + 1
}
continue
}
if c == '\n' {
parse_line(
line.to_string(),
source,
start_line,
variables,
overrides,
defined_variables,
fields,
entries,
diagnostics,
budget,
)
limit_document_diagnostics(diagnostics, budget)
if budget.exhausted {
break
}
line.reset()
physical_line = physical_line + 1
start_line = physical_line
} else {
line.write_char(c)
}
i = i + 1
}
parse_line(
line.to_string(),
source,
start_line,
variables,
overrides,
defined_variables,
fields,
entries,
diagnostics,
budget,
)
limit_document_diagnostics(diagnostics, budget)
{ entries, diagnostics, }
}
///|
fn limit_document_diagnostics(
diagnostics : Array[Diagnostic],
budget : ExpansionBudget,
) -> Unit {
if diagnostics.length() > 100 && diagnostics[100].code != "PC021" {
let omitted = diagnostics.pop().unwrap()
diagnostics.push(
diag(
"PC021",
"Diagnostic limit of 100 reached; parsing stopped before further diagnostics",
omitted.location,
),
)
budget.exhausted = true
}
}
///|
fn parse_line(
text : String,
source : String,
line_number : Int32,
variables : Map[String, String],
overrides : Map[String, String],
defined_variables : Map[String, Bool],
fields : Map[String, Bool],
entries : Array[Entry],
diagnostics : Array[Diagnostic],
budget : ExpansionBudget,
) -> Unit {
if budget.exhausted || text.is_blank() {
return
}
let chars = text.to_array()
let mut start = 0
while start < chars.length() && whitespace(chars[start]) {
start = start + 1
}
let location = { source, line: line_number, column: start + 1, }
let mut split = start
while split < chars.length() && chars[split] != '=' && chars[split] != ':' {
split = split + 1
}
if split == chars.length() {
diagnostics.push(
diag("PC001", "Expected variable=value or Field: value", location),
)
return
}
let name = chars_text(chars, start, split).trim().to_owned()
if !valid_key(name) {
diagnostics.push(diag("PC002", "Invalid key: " + name, location))
return
}
let kind = if chars[split] == '=' { Variable } else { Field }
let field_name = field_key(name)
if (
kind == Variable &&
(name == "pcfiledir" || defined_variables.contains(name))
) ||
(kind == Field && fields.contains(field_name)) {
diagnostics.push(
diag("PC005", "Duplicate or reserved definition: " + name, location),
)
return
}
let mut value_start = split + 1
while value_start < chars.length() && whitespace(chars[value_start]) {
value_start = value_start + 1
}
let raw = chars_text(chars, value_start, chars.length()).trim_end().to_owned()
let value_location = { ..location, column: value_start + 1, }
let document_limited = budget.remaining < 1048576
let limit = if document_limited { budget.remaining } else { 1048576 }
let limit_message = if document_limited {
"Document expansion exceeds 1 Mi UTF-16 code units"
} else {
"Expanded value exceeds 1 Mi UTF-16 code units"
}
match expand_limited(raw, variables, value_location, limit, limit_message) {
Err(d) => {
diagnostics.push(d)
if d.code == "PC009" {
budget.exhausted = true
}
}
Ok(expanded_value) => {
let value = match kind {
Variable => overrides.get(name).unwrap_or(expanded_value)
Field => expanded_value
}
// Overrides must not bypass either limit. Count discarded expansion work
// too, so many overridden values cannot repeatedly consume the budget.
let size = if value.length() > expanded_value.length() {
value.length()
} else {
expanded_value.length()
}
if size > limit {
diagnostics.push(diag("PC009", limit_message, value_location))
budget.exhausted = true
return
}
budget.remaining = budget.remaining - size
entries.push({ kind, name, raw, value, location, value_location, })
match kind {
Variable => {
variables[name] = value
defined_variables[name] = true
}
Field => fields[field_name] = true
}
}
}
}