///|
/// A simple editable operation over a parsed document.
pub enum DocumentEdit {
SetValue(String, String)
RemoveValue(String)
RenameValue(String, String)
} derive(@debug.Debug, Eq)
///|
pub fn set_value(path : String, value : String) -> DocumentEdit {
SetValue(path, value)
}
///|
pub fn remove_value(path : String) -> DocumentEdit {
RemoveValue(path)
}
///|
pub fn rename_value(old_path : String, new_path : String) -> DocumentEdit {
RenameValue(old_path, new_path)
}
///|
/// Returns entries from one section.
pub fn ConfigDocument::section_entries(
self : ConfigDocument,
section : String,
) -> Array[ConfigEntry] {
let result : Array[ConfigEntry] = []
for entry in self.entries {
if entry.section == section {
result.push(entry)
}
}
result
}
///|
/// Returns all section names in declaration order.
pub fn ConfigDocument::sections(self : ConfigDocument) -> Array[String] {
let result : Array[String] = []
for entry in self.entries {
if entry.section != "" && !doc_contains(result, entry.section) {
result.push(entry.section)
}
}
result
}
///|
/// Finds the last entry matching a path.
pub fn ConfigDocument::get_path(
self : ConfigDocument,
path : String,
) -> String? {
let mut found : String? = None
for entry in self.entries {
if entry.path() == path {
found = Some(entry.value)
}
}
found
}
///|
/// Returns all duplicate paths in a document.
pub fn ConfigDocument::duplicate_paths(self : ConfigDocument) -> Array[String] {
let seen : Array[String] = []
let duplicates : Array[String] = []
for entry in self.entries {
let path = entry.path()
if doc_contains(seen, path) && !doc_contains(duplicates, path) {
duplicates.push(path)
}
seen.push(path)
}
duplicates
}
///|
/// Creates a new document with an edited value.
pub fn ConfigDocument::with_value(
self : ConfigDocument,
path : String,
value : String,
) -> ConfigDocument {
let entries : Array[ConfigEntry] = []
let mut replaced = false
for entry in self.entries {
if entry.path() == path {
entries.push({
section: entry.section,
key: entry.key,
value,
source: entry.source,
span: entry.span,
comments: entry.comments,
})
replaced = true
} else {
entries.push(entry)
}
}
if !replaced {
let parts = split_path(path)
entries.push({
section: parts.0,
key: parts.1,
value,
source: self.name,
span: span(self.name, self.entries.length() + 1, 1),
comments: [],
})
}
{ name: self.name, entries, diagnostics: self.diagnostics }
}
///|
/// Creates a new document without a path.
pub fn ConfigDocument::without_path(
self : ConfigDocument,
path : String,
) -> ConfigDocument {
let entries : Array[ConfigEntry] = []
for entry in self.entries {
if entry.path() != path {
entries.push(entry)
}
}
{ name: self.name, entries, diagnostics: self.diagnostics }
}
///|
/// Renames an entry path while preserving value and source metadata.
pub fn ConfigDocument::rename_path(
self : ConfigDocument,
old_path : String,
new_path : String,
) -> ConfigDocument {
let entries : Array[ConfigEntry] = []
let parts = split_path(new_path)
for entry in self.entries {
if entry.path() == old_path {
entries.push({
section: parts.0,
key: parts.1,
value: entry.value,
source: entry.source,
span: entry.span,
comments: entry.comments,
})
} else {
entries.push(entry)
}
}
{ name: self.name, entries, diagnostics: self.diagnostics }
}
///|
/// Applies a sequence of document edits.
pub fn ConfigDocument::apply_edits(
self : ConfigDocument,
edits : Array[DocumentEdit],
) -> ConfigDocument {
let mut doc = self
for edit in edits {
match edit {
SetValue(path, value) => doc = doc.with_value(path, value)
RemoveValue(path) => doc = doc.without_path(path)
RenameValue(old_path, new_path) =>
doc = doc.rename_path(old_path, new_path)
}
}
doc
}
///|
fn split_path(path : String) -> (String, String) {
let parts = path.split(".").map(part => part.to_owned()).collect()
if parts.length() <= 1 {
("", path)
} else {
let key = parts[parts.length() - 1]
let section_parts = parts[0:parts.length() - 1]
(section_parts.join("."), key)
}
}
///|
fn doc_contains(values : Array[String], target : String) -> Bool {
for value in values {
if value == target {
return true
}
}
false
}
///|
/// Extracts a document containing only keys below a section.
pub fn ConfigDocument::subdocument(
self : ConfigDocument,
section : String,
) -> ConfigDocument {
let entries : Array[ConfigEntry] = []
for entry in self.entries {
if entry.section == section || entry.section.has_prefix(section + ".") {
entries.push(entry)
}
}
{ name: self.name + "#" + section, entries, diagnostics: self.diagnostics }
}
///|
/// Renders a document directly as INI, preserving the current entry order.
pub fn ConfigDocument::render_document_ini(self : ConfigDocument) -> String {
let builder = StringBuilder()
let mut current_section = ""
for entry in self.entries {
if entry.section != current_section {
if builder.to_string() != "" {
builder.write_string("\n")
}
current_section = entry.section
if current_section != "" {
builder.write_string("[" + current_section + "]\n")
}
}
for comment in entry.comments {
builder.write_string("; " + comment + "\n")
}
builder.write_string(entry.key + " = " + entry.value + "\n")
}
builder.to_string()
}
///|
/// Creates an INI patch from edits without mutating the original document.
pub fn render_edit_patch(
document : ConfigDocument,
edits : Array[DocumentEdit],
) -> String {
let edited = document.apply_edits(edits)
let diff = diff_views(
merge_layers([layer("before", 0, document)]),
merge_layers([layer("after", 0, edited)]),
)
render_diff_markdown(diff)
}
///|
/// Returns entries whose key names match a prefix.
pub fn ConfigDocument::find_key_prefix(
self : ConfigDocument,
prefix : String,
) -> Array[ConfigEntry] {
let entries : Array[ConfigEntry] = []
for entry in self.entries {
if entry.key.has_prefix(prefix) || entry.path().has_prefix(prefix) {
entries.push(entry)
}
}
entries
}
///|
/// Returns a warning for keys that do not follow lowercase dot/underscore style.
pub fn ConfigDocument::key_style_diagnostics(
self : ConfigDocument,
) -> Array[Diagnostic] {
let diagnostics : Array[Diagnostic] = []
for entry in self.entries {
let path = entry.path()
if !is_style_key(path) {
diagnostics.push(
warning(
"key-style",
"key '" +
path +
"' should use lowercase letters, digits, dots, dashes, or underscores",
span=Some(entry.span),
),
)
}
}
diagnostics
}
///|
fn is_style_key(path : String) -> Bool {
let mut index = 0
while index < path.length() {
let ch = path[index]
let ok = (ch >= 'a' && ch <= 'z') ||
(ch >= '0' && ch <= '9') ||
ch == '.' ||
ch == '_' ||
ch == '-'
if !ok {
return false
}
index = index + 1
}
true
}