///| 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
}