///| Parses INI text using default tolerant options.
pub fn parse_ini(name : String, text : String) -> ParseResult {
parse_ini_with_options(name, text, default_parse_options())
}
///| Parses Java-style properties text using defaults close to INI mode.
pub fn parse_properties(name : String, text : String) -> ParseResult {
let options = default_parse_options()
parse_properties_with_options(name, text, options)
}
///| Parses INI text with explicit options.
pub fn parse_ini_with_options(name : String, text : String, options : ParseOptions) -> ParseResult {
parse_lines(name, split_lines(normalize_newlines(text)), options, false)
}
///| Parses properties text with explicit options.
pub fn parse_properties_with_options(name : String, text : String, options : ParseOptions) -> ParseResult {
parse_lines(name, join_property_continuations(split_lines(normalize_newlines(text))), options, true)
}
fn parse_lines(name : String, lines : Array[String], options : ParseOptions, properties_mode : Bool) -> ParseResult {
let entries : Array[ConfigEntry] = []
let diagnostics : Array[Diagnostic] = []
let mut section = ""
let mut pending_comments : Array[String] = []
let seen : Array[String] = []
let mut line_number = 1
for raw_line in lines {
let trimmed = raw_line.trim()
if trimmed == "" {
if !options.preserve_comments {
pending_comments = []
}
} else if is_comment(trimmed.to_owned()) {
if options.preserve_comments {
pending_comments.push(strip_comment_marker(trimmed.to_owned()))
}
} else if !properties_mode && is_section_line(trimmed.to_owned()) {
let parsed = parse_section_name(trimmed.to_owned())
if parsed == "" && options.strict_sections {
diagnostics.push(error("empty-section", "empty section names are not allowed", span=Some(span(name, line_number, 1))))
}
section = parsed
pending_comments = []
} else {
let sep = find_separator(raw_line, options.allow_colon_separator)
match sep {
None => {
if options.allow_empty_value {
let key = trimmed.to_owned()
add_entry(
entries,
diagnostics,
seen,
section,
key,
"",
name,
line_number,
pending_comments,
)
pending_comments = []
} else {
diagnostics.push(error("missing-separator", "expected '=' or ':' between key and value", span=Some(span(name, line_number, 1))))
}
}
Some(index) => {
let key = raw_line[0:index].to_owned().trim().to_owned()
let value = raw_line[index + 1:raw_line.length()].to_owned().trim().to_owned()
if key == "" {
diagnostics.push(error("empty-key", "configuration keys must not be empty", span=Some(span(name, line_number, 1))))
} else {
add_entry(
entries,
diagnostics,
seen,
section,
key,
unquote_value(value),
name,
line_number,
pending_comments,
)
}
pending_comments = []
}
}
}
line_number = line_number + 1
}
let document = { name, entries, diagnostics }
{ document, ok: diagnostics_ok(diagnostics) }
}
fn add_entry(
entries : Array[ConfigEntry],
diagnostics : Array[Diagnostic],
seen : Array[String],
section : String,
key : String,
value : String,
source : String,
line_number : Int,
comments : Array[String],
) -> Unit {
let path = entry_path(section, key)
if contains_string(seen, path) {
diagnostics.push(warning("duplicate-key", "duplicate key '" + path + "' overrides an earlier value", span=Some(span(source, line_number, 1))))
}
seen.push(path)
entries.push({
section,
key,
value,
source,
span: span(source, line_number, 1),
comments: comments.copy(),
})
}
fn normalize_newlines(text : String) -> String {
text.replace(old="\r\n", new="\n").replace(old="\r", new="\n")
}
fn split_lines(text : String) -> Array[String] {
text.split("\n").map(line => line.to_owned()).collect()
}
fn is_comment(text : String) -> Bool {
text.has_prefix(";") || text.has_prefix("#")
}
fn strip_comment_marker(text : String) -> String {
if text.length() <= 1 {
""
} else {
text[1:text.length()].to_owned().trim().to_owned()
}
}
fn is_section_line(text : String) -> Bool {
text.has_prefix("[") && text.has_suffix("]")
}
fn parse_section_name(text : String) -> String {
if text.length() <= 2 {
""
} else {
text[1:text.length() - 1].to_owned().trim().to_owned()
}
}
fn find_separator(line : String, allow_colon : Bool) -> Int? {
let mut index = 0
let mut escaped = false
while index < line.length() {
let ch = line[index]
if escaped {
escaped = false
} else if ch == '\\' {
escaped = true
} else if ch == '=' || (allow_colon && ch == ':') {
return Some(index)
}
index = index + 1
}
None
}
fn unquote_value(value : String) -> String {
if value.length() >= 2 {
if (value.has_prefix("\"") && value.has_suffix("\"")) || (value.has_prefix("'") && value.has_suffix("'")) {
value[1:value.length() - 1].to_owned()
} else {
value
}
} else {
value
}
}
fn contains_string(values : Array[String], target : String) -> Bool {
for value in values {
if value == target {
return true
}
}
false
}
fn join_property_continuations(lines : Array[String]) -> Array[String] {
let joined : Array[String] = []
let mut current = ""
let mut active = false
for line in lines {
if active {
current = current + line.trim().to_owned()
} else {
current = line
active = true
}
if ends_with_unescaped_backslash(current) {
current = current[0:current.length() - 1].to_owned()
} else {
joined.push(current)
current = ""
active = false
}
}
if active {
joined.push(current)
}
joined
}
fn ends_with_unescaped_backslash(text : String) -> Bool {
if text.length() == 0 || !text.has_suffix("\\") {
false
} else {
let mut count = 0
let mut index = text.length() - 1
while index >= 0 && text[index] == '\\' {
count = count + 1
index = index - 1
}
count % 2 == 1
}
}