///|
/// The entries shrc manages: an environment variable or a shell alias.
///
/// An entry is stored in a small manifest so that `shrc ls` and `shrc all`
/// always agree with what was written to the shell files, even after a file
/// has been edited or deleted by hand.
///|
/// Whether an entry is an environment variable or an alias.
pub(all) enum EntryKind {
Env
Alias
}
///|
/// Whether two kinds are the same. Kept explicit so the enum needs no
/// derived `Eq`, whose promoted methods would otherwise warn.
pub fn kind_eq(a : EntryKind, b : EntryKind) -> Bool {
match (a, b) {
(Env, Env) => true
(Alias, Alias) => true
_ => false
}
}
///|
/// A single managed entry.
///
/// `raw` marks a value that is shell syntax rather than a literal string: an
/// adopted `export PATH="$HOME/bin:$PATH"` must keep expanding when the
/// startup file is read, so rendering embeds it in the family's interpolating
/// quotes instead of literal single quotes. `list` marks a search path — the
/// value is joined by `:` canonically and rendered with the family's own
/// separator (PowerShell uses `;`).
pub(all) struct Entry {
kind : EntryKind
name : String
value : String
raw : Bool
list : Bool
imported : Bool
}
///|
/// Build a literal entry.
pub fn Entry::new(kind : EntryKind, name : String, value : String) -> Entry {
{ kind, name, value, raw: false, list: false, imported: false, }
}
///|
/// Build an entry whose value stays live shell syntax (`$VAR`, backticks).
pub fn Entry::new_raw(kind : EntryKind, name : String, value : String) -> Entry {
{ kind, name, value, raw: true, list: false, imported: false, }
}
///|
/// Build an entry with every attribute spelled out.
pub fn Entry::new_full(
kind : EntryKind,
name : String,
value : String,
raw : Bool,
list : Bool,
imported~ : Bool = false,
) -> Entry {
{ kind, name, value, raw, list, imported, }
}
///|
/// Whether two entries agree on kind, name, value and attributes.
pub fn entry_eq(a : Entry, b : Entry) -> Bool {
kind_eq(a.kind, b.kind) &&
a.name == b.name &&
a.value == b.value &&
a.raw == b.raw &&
a.list == b.list &&
a.imported == b.imported
}
///|
/// The manifest spelling of a kind.
pub fn kind_label(kind : EntryKind) -> String {
match kind {
Env => "env"
Alias => "alias"
}
}
///|
/// Parse a manifest kind, accepting the singular and plural spellings.
pub fn parse_kind(text : String) -> EntryKind? {
match text {
"env" | "environment" => Some(Env)
"alias" | "aliases" => Some(Alias)
_ => None
}
}
///|
/// Escape a value so it can live on one tab-separated manifest line.
pub fn escape_field(text : String) -> String {
let out = StringBuilder()
for ch in text.to_array() {
match ch {
'\\' => out.write_string("\\\\")
'\t' => out.write_string("\\t")
'\n' => out.write_string("\\n")
'\r' => out.write_string("\\r")
_ => out.write_char(ch)
}
}
out.to_string()
}
///|
/// Inverse of `escape_field`.
pub fn unescape_field(text : String) -> String {
let out = StringBuilder()
let chars = text.to_array()
let mut i = 0
while i < chars.length() {
let ch = chars[i]
if ch == '\\' && i + 1 < chars.length() {
match chars[i + 1] {
'n' => {
out.write_char('\n')
i = i + 2
}
't' => {
out.write_char('\t')
i = i + 2
}
'r' => {
out.write_char('\r')
i = i + 2
}
'\\' => {
out.write_char('\\')
i = i + 2
}
_ => {
out.write_char(ch)
i = i + 1
}
}
} else {
out.write_char(ch)
i = i + 1
}
}
out.to_string()
}
///|
/// The attribute field of a manifest line: empty, or a comma-separated list of
/// `raw` (the value stays live shell syntax) and `list` (a search path).
fn entry_flags(entry : Entry) -> String {
let parts = []
if entry.raw {
parts.push("raw")
}
if entry.list {
parts.push("list")
}
if entry.imported {
parts.push("imported")
}
parts.join(",")
}
///|
/// Serialize one entry as a manifest line: `kindnamevalue`, with an
/// extra attribute field before the name when the value is not a plain
/// literal (`raw`, `list`, or `raw,list`).
pub fn entry_to_line(entry : Entry) -> String {
let flags = entry_flags(entry)
if flags.length() == 0 {
"\{kind_label(entry.kind)}\t\{entry.name}\t\{escape_field(entry.value)}"
} else {
"\{kind_label(entry.kind)}\t\{flags}\t\{entry.name}\t\{escape_field(entry.value)}"
}
}
///|
/// Parse one manifest line, or `None` when it is blank or malformed.
pub fn entry_of_line(line : String) -> Entry? {
let trimmed = line.trim().to_owned()
if trimmed.length() == 0 || trimmed.has_prefix("#") {
return None
}
let parts = trimmed.split("\t").to_array()
if parts.length() < 3 {
return None
}
let kind = match parse_kind(parts[0].to_owned()) {
Some(kind) => kind
None => return None
}
if parts.length() >= 4 {
let mut raw = false
let mut list = false
let mut imported = false
for flag in parts[1].split(",") {
if flag == "raw" {
raw = true
} else if flag == "list" {
list = true
} else if flag == "imported" {
imported = true
}
}
return Some(
Entry::new_full(
kind,
parts[2].to_owned(),
unescape_field(parts[3].to_owned()),
raw,
list,
imported~,
),
)
}
Some(
Entry::new(kind, parts[1].to_owned(), unescape_field(parts[2].to_owned())),
)
}
///|
/// Parse a whole manifest.
pub fn parse_entries(text : String) -> Array[Entry] {
let out = []
for line in text.split("\n") {
match entry_of_line(line.to_owned()) {
Some(entry) => out.push(entry)
None => ()
}
}
out
}
///|
/// Render the whole manifest, one entry per line.
pub fn serialize_entries(entries : Array[Entry]) -> String {
let out = StringBuilder()
for entry in entries {
out.write_string(entry_to_line(entry))
out.write_char('\n')
}
out.to_string()
}
///|
/// Insert `entry`, replacing any entry with the same kind and name.
pub fn upsert_entry(entries : Array[Entry], entry : Entry) -> Array[Entry] {
let out = []
let mut replaced = false
for existing in entries {
if kind_eq(existing.kind, entry.kind) && existing.name == entry.name {
out.push(entry)
replaced = true
} else {
out.push(existing)
}
}
if !replaced {
out.push(entry)
}
out
}
///|
/// Split `entries` into the ones that survive and the ones matching `name`.
pub fn remove_by_name(
entries : Array[Entry],
name : String,
) -> (Array[Entry], Array[Entry]) {
let remaining = []
let removed = []
for entry in entries {
if entry.name == name {
removed.push(entry)
} else {
remaining.push(entry)
}
}
(remaining, removed)
}
///|
/// Entries whose name equals or contains `filter`, in manifest order.
pub fn filter_entries(entries : Array[Entry], filter : String) -> Array[Entry] {
if filter.length() == 0 {
return entries
}
let out = []
for entry in entries {
if entry.name == filter || entry.name.contains(filter) {
out.push(entry)
}
}
out
}