///|
/// `shrc init` support: adopt the environment variables and aliases a user
/// already has in their startup files.
///
/// Only top-level lines are candidates: a definition indented inside an `if`,
/// a `case` or a function belongs to that construct and is never moved. A
/// line that cannot be moved faithfully is left where it is and counted, so
/// the summary can say how much was skipped instead of silently moving half
/// of it.
///|
/// How one line of a startup file was handled.
priv enum LineScan {
Adopt(Entry)
Retain
Ignore
}
///|
/// One scanned startup file.
pub(all) struct FileScan {
/// The file text with the adopted top-level lines removed.
cleaned : String
/// The entries that could be adopted, in file order.
entries : Array[Entry]
/// Lines that look like definitions but could not be moved.
retained : Int32
}
///|
/// A variable name starts with a letter or `_`.
fn is_name_start(ch : Char) -> Bool {
(ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_'
}
///|
/// The rest of a variable name is letters, digits or `_`.
fn is_name_char(ch : Char) -> Bool {
is_name_start(ch) || (ch >= '0' && ch <= '9')
}
///|
/// Read the variable name that begins at `chars[i]`.
fn read_name(chars : Array[Char], i : Int32) -> (String, Int32)? {
if i >= chars.length() || !is_name_start(chars[i]) {
return None
}
let out = StringBuilder()
let mut j = i
while j < chars.length() && is_name_char(chars[j]) {
out.write_char(chars[j])
j = j + 1
}
Some((out.to_string(), j))
}
///|
/// Parse the variable reference that starts at the `$` at `chars[i]`:
/// `$NAME`, `${NAME}`, or the `$env.NAME` / `$env:NAME` spellings of nushell
/// and PowerShell. Returns the variable name and the index just past the
/// reference.
fn scan_ref(chars : Array[Char], i : Int32) -> (String, Int32)? {
let j = i + 1
if j >= chars.length() {
return None
}
if chars[j] == '{' {
match read_name(chars, j + 1) {
Some((name, next)) =>
if next < chars.length() && chars[next] == '}' {
Some((name, next + 1))
} else {
None
}
None => None
}
} else {
match read_name(chars, j) {
Some((name, next)) =>
if name == "env" &&
next < chars.length() &&
(chars[next] == '.' || chars[next] == ':') {
read_name(chars, next + 1)
} else {
Some((name, next))
}
None => None
}
}
}
///|
/// Whether every `$` in `text` is a variable reference shrc can translate
/// between shells. Command substitutions and backticks cannot be translated,
/// so a value containing them is not adopted.
pub fn refs_translatable(text : String) -> Bool {
if text.contains("`") {
return false
}
let chars = text.to_array()
let mut i = 0
while i < chars.length() {
if chars[i] == '$' {
match scan_ref(chars, i) {
Some((_, next)) => i = next
None => return false
}
} else {
i = i + 1
}
}
true
}
///|
/// The spelling of a variable reference in the syntax of `family`.
fn ref_text(family : ShellFamily, name : String) -> String {
match family {
Posix | Fish | Csh => "$\{name}"
Nu => "$env.\{name}"
PowerShell => "$env:\{name}"
}
}
///|
/// Rewrite every variable reference in `value` for the syntax of `family`.
/// Values are stored canonically (`$NAME`), so a value adopted from nushell
/// or PowerShell renders correctly in every other family.
pub fn translate_refs(family : ShellFamily, value : String) -> String {
let chars = value.to_array()
let out = StringBuilder()
let mut i = 0
while i < chars.length() {
if chars[i] == '$' {
match scan_ref(chars, i) {
Some((name, next)) => {
out.write_string(ref_text(family, name))
i = next
}
None => {
out.write_char('$')
i = i + 1
}
}
} else {
out.write_char(chars[i])
i = i + 1
}
}
out.to_string()
}
///|
/// Whether `text` uses shell syntax that must stay live when rendered.
fn contains_live(text : String) -> Bool {
text.contains("$") || text.contains("`")
}
///|
/// Whether an unquoted word can be moved as a literal value. Characters the
/// shell would treat as syntax, and `~` / `[...]` whose expansion depends on
/// position, disqualify it.
fn bare_value_ok(text : String) -> Bool {
for forbidden in [";", "|", "&", "<", ">", "(", ")", "{", "}", "[", "]", "~"] {
if text.contains(forbidden) {
return false
}
}
!text.has_prefix("#")
}
///|
/// Remove leading spaces and tabs.
fn strip_leading_ws(text : String) -> String {
let chars = text.to_array()
let mut i = 0
while i < chars.length() && (chars[i] == ' ' || chars[i] == '\t') {
i = i + 1
}
if i == 0 {
text
} else {
text.view(start_offset=i).to_owned()
}
}
///|
/// The text after `keyword` when `line` begins a `keyword ...` command, or
/// `None`. The keyword must be followed by whitespace, so `exported=1` is not
/// taken for an `export`.
fn after_keyword(line : String, keyword : String) -> String? {
if !line.has_prefix(keyword) {
return None
}
let rest = line.view(start_offset=keyword.length()).to_owned()
if rest.length() == 0 {
return None
}
let first = rest.to_array()[0]
if first != ' ' && first != '\t' {
return None
}
Some(strip_leading_ws(rest))
}
///|
/// Split a command line into words, keeping single- and double-quoted spans
/// together. `None` when a quote is left open.
fn split_words(text : String) -> Array[String]? {
let words = []
let chars = text.to_array()
let mut i = 0
while i < chars.length() {
while i < chars.length() && (chars[i] == ' ' || chars[i] == '\t') {
i = i + 1
}
if i >= chars.length() {
break
}
let start = i
let mut quote : Char? = None
while i < chars.length() {
let ch = chars[i]
match quote {
Some(open) => if ch == open { quote = None } else { () }
None => if ch == '\'' || ch == '"' { quote = Some(ch) } else { () }
}
if quote is None && (chars[i] == ' ' || chars[i] == '\t') {
break
}
i = i + 1
}
if quote is Some(_) {
return None
}
words.push(text.view(start_offset=start, end_offset=i).to_owned())
}
Some(words)
}
///|
/// The shape of the text on the right of an assignment.
priv enum ValueForm {
SingleQuoted(String)
DoubleQuoted(String)
BareWord(String)
Unparsed
}
///|
/// Parse the text after `NAME=`: a single-quoted literal, a double-quoted
/// string, or a bare word (with an optional trailing comment). Anything else
/// — concatenations, escapes, a trailing command — is `Unparsed`.
fn parse_rhs(rhs : String) -> ValueForm {
let chars = rhs.to_array()
let len = chars.length()
if len == 0 {
return BareWord("")
}
let first = chars[0]
if first == '\'' || first == '"' {
if len >= 2 && chars[len - 1] == first {
let inner = rhs.view(start_offset=1, end_offset=len - 1).to_owned()
let has_quote = if first == '\'' {
inner.contains("'")
} else {
inner.contains("\"")
}
if !has_quote {
return if first == '\'' {
SingleQuoted(inner)
} else {
DoubleQuoted(inner)
}
}
}
return Unparsed
}
let mut end = 0
while end < len && chars[end] != ' ' && chars[end] != '\t' {
end = end + 1
}
let token = rhs.view(end_offset=end).to_owned()
let rest = rhs.view(start_offset=end).to_owned().to_array()
let mut i = 0
while i < rest.length() && (rest[i] == ' ' || rest[i] == '\t') {
i = i + 1
}
if i < rest.length() && rest[i] != '#' {
return Unparsed
}
if token.contains("'") || token.contains("\"") {
return Unparsed
}
BareWord(token)
}
///|
/// The entry for an adopted environment variable. A search path is marked as
/// a list so rendering can use the family's separator and `shrc ls` can show
/// each directory on its own row.
fn env_entry(name : String, value : String, raw : Bool) -> Entry {
Entry::new_full(
Env,
name,
value,
raw,
is_search_path_name(name),
imported=true,
)
}
///|
/// Adopt `text` — the inside of a pair of double quotes, or an unquoted word —
/// as the value of environment variable `name`.
fn scan_env_text(name : String, text : String, bare : Bool) -> LineScan {
if text.contains("\\") {
return Retain
}
if bare && !bare_value_ok(text) {
return Retain
}
if !contains_live(text) {
return Adopt(env_entry(name, text, false))
}
if !refs_translatable(text) ||
text.contains("[") ||
text.contains("]") ||
text.contains("(") ||
text.contains(")") {
return Retain
}
Adopt(env_entry(name, translate_refs(Posix, text), true))
}
///|
/// Adopt `rhs` as the value of environment variable `name`.
fn scan_env_value(name : String, rhs : String) -> LineScan {
if !is_valid_env_name(name) {
return Retain
}
match parse_rhs(rhs) {
SingleQuoted(value) => Adopt(env_entry(name, value, false))
DoubleQuoted(inner) => scan_env_text(name, inner, false)
BareWord(token) => scan_env_text(name, token, true)
Unparsed => Retain
}
}
///|
/// The literal text of one alias word, or `None` when it uses variables or
/// escapes and cannot be moved literally.
fn alias_word_text(word : String) -> String? {
match parse_rhs(word) {
SingleQuoted(value) => Some(value)
DoubleQuoted(inner) =>
if contains_live(inner) || inner.contains("\\") {
None
} else {
Some(inner)
}
BareWord(token) =>
if token.length() == 0 ||
contains_live(token) ||
token.contains("\\") ||
!bare_value_ok(token) {
None
} else {
Some(token)
}
Unparsed => None
}
}
///|
/// Adopt `rhs` as the value of alias `name`.
fn scan_alias_value(name : String, rhs : String) -> LineScan {
if !is_valid_alias_name(name) {
return Retain
}
match alias_word_text(rhs) {
Some(value) =>
Adopt(Entry::new_full(Alias, name, value, false, false, imported=true))
None => Retain
}
}
///|
/// Whether `name` is spelled like an environment variable: valid, and with no
/// lowercase letters. A bare assignment of a mixed-case name is usually a
/// shell-local helper (`__conda_setup=...`), not an environment variable.
fn is_env_style_name(name : String) -> Bool {
if !is_valid_env_name(name) {
return false
}
for ch in name.to_array() {
if ch >= 'a' && ch <= 'z' {
return false
}
}
true
}
///|
/// `NAME=VALUE` from an `export NAME=VALUE` statement.
fn posix_assign(rest : String) -> LineScan {
match split_eq(rest) {
Some((name, rhs)) => scan_env_value(name, rhs)
None => Ignore
}
}
///|
/// Scan a line of a POSIX shell startup file (`~/.bashrc`, `~/.zshrc`, ...):
/// `export NAME=VALUE`, `alias NAME=VALUE`, and bare `NAME=VALUE`.
fn scan_posix(line : String) -> LineScan {
match after_keyword(line, "export") {
Some(rest) => posix_assign(rest)
None =>
match after_keyword(line, "alias") {
Some(rest) => posix_assign_alias(rest)
None =>
match split_eq(line) {
Some((name, rhs)) =>
// A bare assignment the shell does not export: still a variable
// the user has, so it is managed like one — when it is spelled
// like an environment variable.
if is_env_style_name(name) {
scan_env_value(name, rhs)
} else {
Ignore
}
None => Ignore
}
}
}
}
///|
/// `NAME=VALUE` from an `alias NAME=VALUE` statement.
fn posix_assign_alias(rest : String) -> LineScan {
match split_eq(rest) {
Some((name, rhs)) => scan_alias_value(name, rhs)
None => Ignore
}
}
///|
/// `alias NAME VALUE...` in fish and csh, where the value is the command line
/// the name expands to. A trailing `#` comment is not part of the alias.
fn alias_list_line(rest : String) -> LineScan {
let words = match split_words(rest) {
Some(words) => words
None => return Retain
}
if words.length() == 0 {
return Ignore
}
let name = words[0]
if !is_valid_alias_name(name) {
return Retain
}
if words.length() == 1 {
return Ignore
}
let parts = []
let mut i = 1
while i < words.length() {
if words[i].has_prefix("#") {
break
}
match alias_word_text(words[i]) {
Some(text) => parts.push(text)
None => return Retain
}
i = i + 1
}
if parts.length() == 0 {
return Ignore
}
Adopt(
Entry::new_full(Alias, name, parts.join(" "), false, false, imported=true),
)
}
///|
/// `set -gx NAME VALUE` in fish. Only an exported variable with exactly one
/// value is adopted; a fish list value has no equivalent in the other shells.
fn fish_set(rest : String) -> LineScan {
let words = match split_words(rest) {
Some(words) => words
None => return Retain
}
let mut manageable = false
let mut exported = false
let mut i = 0
while i < words.length() && words[i].has_prefix("-") && words[i] != "-" {
let flag = words[i]
if flag == "--export" {
manageable = true
exported = true
i = i + 1
} else if flag == "--global" || flag == "--universal" {
manageable = true
i = i + 1
} else if flag == "--function" || flag == "--local" {
// A variable scoped to the script or a function never outlives the
// file that defines it.
return Ignore
} else if flag == "--query" || flag == "--unexport" {
return Ignore
} else if flag == "--append" || flag == "--prepend" {
return Retain
} else if flag.has_prefix("--") {
return Retain
} else {
let letters = flag.view(start_offset=1).to_owned()
let mut known = true
for ch in letters.to_array() {
if ch == 'x' {
manageable = true
exported = true
} else if ch == 'g' || ch == 'U' {
manageable = true
} else if ch == 'f' || ch == 'l' || ch == 'q' || ch == 'u' {
return Ignore
} else if ch == 'a' || ch == 'p' {
return Retain
} else {
known = false
}
}
if !known {
return Retain
}
i = i + 1
}
}
if !manageable || i >= words.length() {
// `set NAME VALUE` without a scope is local to the file that writes it.
return Ignore
}
let name = words[i]
if !exported && !is_env_style_name(name) {
// A global that fish itself uses (`set -g fish_greeting ...`) is not an
// environment variable.
return Ignore
}
let values = words.exact_view(start=i + 1).to_owned()
if values.length() == 0 || values[0].has_prefix("#") {
return Ignore
}
if values.length() > 1 {
return Retain
}
scan_env_value(name, values[0])
}
///|
/// Scan a line of a fish startup file (`~/.config/fish/config.fish`).
fn scan_fish(line : String) -> LineScan {
match after_keyword(line, "set") {
Some(rest) => fish_set(rest)
None =>
match after_keyword(line, "alias") {
Some(rest) => alias_list_line(rest)
None => Ignore
}
}
}
///|
/// `setenv NAME VALUE` in csh.
fn csh_setenv(rest : String) -> LineScan {
let words = match split_words(rest) {
Some(words) => words
None => return Retain
}
if words.length() < 2 {
return Ignore
}
if words.length() > 2 {
return Retain
}
if words[1].has_prefix("#") {
return Ignore
}
scan_env_value(words[0], words[1])
}
///|
/// Scan a line of a csh/tcsh startup file.
fn scan_csh(line : String) -> LineScan {
match after_keyword(line, "setenv") {
Some(rest) => csh_setenv(rest)
None =>
match after_keyword(line, "alias") {
Some(rest) => alias_list_line(rest)
None => Ignore
}
}
}
///|
/// A nushell environment value: `'literal'`, `"literal"`, or the
/// interpolating `$"..."` / `$'...'`.
fn nu_value(name : String, value : String) -> LineScan {
if value.has_prefix("$'") || value.has_prefix("$\"") {
let chars = value.to_array()
let quote = chars[1]
if chars.length() < 3 || chars[chars.length() - 1] != quote {
return Retain
}
let inner = value
.view(start_offset=2, end_offset=value.length() - 1)
.to_owned()
if inner.contains("\\") ||
!refs_translatable(inner) ||
inner.contains("[") ||
inner.contains("]") ||
inner.contains("(") ||
inner.contains(")") {
return Retain
}
Adopt(env_entry(name, translate_refs(Posix, inner), true))
} else {
match parse_rhs(value) {
SingleQuoted(v) => Adopt(env_entry(name, v, false))
DoubleQuoted(inner) =>
if inner.contains("\\") || contains_live(inner) {
Retain
} else {
Adopt(env_entry(name, inner, false))
}
_ => Retain
}
}
}
///|
/// `$env.NAME = VALUE` in nushell: `rest` is the text after `$env.`.
fn nu_env(rest : String) -> LineScan {
match split_eq(rest) {
None => Retain
Some((raw_name, raw_value)) => {
let name = raw_name.trim().to_owned()
let value = raw_value.trim().to_owned()
if !is_valid_env_name(name) {
return Retain
}
if value.length() == 0 {
return Ignore
}
nu_value(name, value)
}
}
}
///|
/// `alias NAME = VALUE` in nushell.
fn nu_alias(rest : String) -> LineScan {
match split_eq(rest) {
None => Ignore
Some((raw_name, raw_value)) => {
let name = raw_name.trim().to_owned()
let value = raw_value.trim().to_owned()
if !is_valid_alias_name(name) {
return Retain
}
if value.length() == 0 {
return Ignore
}
if value.contains("#") || value.contains(";") || value.contains("\\") {
return Retain
}
Adopt(Entry::new_full(Alias, name, value, false, false, imported=true))
}
}
}
///|
/// Scan a line of a nushell config file.
fn scan_nu(line : String) -> LineScan {
if line.has_prefix("$env.") {
nu_env(line.view(start_offset=5).to_owned())
} else if line.has_prefix("${env:") {
match line.find("}") {
Some(end) =>
if end > 6 {
let name = line.view(start_offset=6, end_offset=end).to_owned()
nu_env("\{name}=\{line.view(start_offset=end + 1)}")
} else {
Ignore
}
None => Ignore
}
} else {
match after_keyword(line, "alias") {
Some(rest) => nu_alias(rest)
None => Ignore
}
}
}
///|
/// `NAME = VALUE` from a PowerShell `$env:NAME = VALUE` statement.
fn pwsh_env(rest : String) -> LineScan {
match split_eq(rest) {
None => Ignore
Some((raw_name, raw_value)) => {
let name = raw_name.trim().to_owned()
let value = raw_value.trim().to_owned()
if !is_valid_env_name(name) {
return Retain
}
if value.length() == 0 || value == "$null" {
return Ignore
}
match parse_rhs(value) {
SingleQuoted(v) =>
Adopt(env_entry(name, canonical_list(name, v), false))
DoubleQuoted(inner) =>
if inner.contains("`") || inner.contains("\\") {
Retain
} else {
scan_env_text(name, canonical_list(name, inner), false)
}
BareWord(token) =>
if token.contains("`") {
Retain
} else {
scan_env_text(name, canonical_list(name, token), true)
}
Unparsed => Retain
}
}
}
}
///|
/// Scan a line of a PowerShell profile.
fn scan_pwsh(line : String) -> LineScan {
if line.has_prefix("$env:") {
pwsh_env(line.view(start_offset=5).to_owned())
} else if line.has_prefix("${env:") {
match line.find("}") {
Some(end) =>
if end > 6 {
let name = line.view(start_offset=6, end_offset=end).to_owned()
pwsh_env("\{name}=\{line.view(start_offset=end + 1)}")
} else {
Ignore
}
None => Ignore
}
} else {
Ignore
}
}
///|
/// Scan one line in the syntax of `family`.
fn scan_line(family : ShellFamily, line : String) -> LineScan {
match family {
Posix => scan_posix(line)
Fish => scan_fish(line)
Csh => scan_csh(line)
Nu => scan_nu(line)
PowerShell => scan_pwsh(line)
}
}
///|
/// Join the retained lines back into file text.
fn join_file_lines(lines : Array[String]) -> String {
if lines.length() == 0 {
""
} else {
lines.join("\n") + "\n"
}
}
///|
/// The whitespace that indents `line`.
fn indent_of(line : String) -> String {
let chars = line.to_array()
let mut i = 0
while i < chars.length() && (chars[i] == ' ' || chars[i] == '\t') {
i = i + 1
}
line.view(end_offset=i).to_owned()
}
///|
/// The first word of a line of shell, ignoring indentation.
fn first_word(line : String) -> String {
match split_words(strip_leading_ws(line)) {
Some(words) => if words.length() > 0 { words[0] } else { "" }
None => ""
}
}
///|
/// The first word of every `;`-separated segment of a line, so one-liners
/// like `if true; then ...; fi` count their keywords too.
fn segment_words(line : String) -> Array[String] {
let out = []
for segment in line.split(";") {
let word = first_word(segment.to_owned())
if word.length() > 0 {
out.push(word)
}
}
out
}
///|
/// The conditional nesting change of one line of POSIX shell: `if`, `for`,
/// `while`, `until`, `case` and `select` open a block, `fi`, `done` and
/// `esac` close one.
fn posix_block_delta(line : String) -> Int32 {
let mut delta = 0
for word in segment_words(line) {
if word == "if" ||
word == "for" ||
word == "while" ||
word == "until" ||
word == "case" ||
word == "select" {
delta = delta + 1
} else if word == "fi" || word == "done" || word == "esac" {
delta = delta - 1
}
}
delta
}
///|
/// The brace nesting change of one line of POSIX shell: a line that ends in
/// `{` opens a function body or a group, a line that starts with `}` closes
/// one.
fn brace_delta(line : String) -> Int32 {
let trimmed = strip_leading_ws(line).trim_end().to_owned()
let opens = if trimmed.view().has_suffix("{") { 1 } else { 0 }
let closes = if trimmed.view().has_prefix("}") { 1 } else { 0 }
opens - closes
}
///|
/// The fish nesting change of one line, given the current depths. An `end`
/// closes a function only when every block still open is a function, which is
/// what `depth == functions` means.
fn fish_block_delta(
line : String,
depth_before : Int32,
functions_before : Int32,
) -> (Int32, Int32) {
let mut depth = depth_before
let mut functions = functions_before
for word in segment_words(line) {
if word == "function" ||
word == "if" ||
word == "for" ||
word == "while" ||
word == "switch" ||
word == "begin" {
depth = depth + 1
if word == "function" {
functions = functions + 1
}
} else if word == "end" && depth > 0 {
if functions > 0 && depth == functions {
functions = functions - 1
}
depth = depth - 1
}
}
(depth - depth_before, functions - functions_before)
}
///|
/// The terminator of the here-document a line opens (`< String? {
match line.find("<<") {
None => None
Some(index) => {
let rest = line.view(start_offset=index + 2).to_owned().to_array()
let mut i = 0
if i < rest.length() && rest[i] == '-' {
i = i + 1
}
if i >= rest.length() {
return None
}
if rest[i] == '\'' || rest[i] == '"' {
let quote = rest[i]
i = i + 1
let out = StringBuilder()
while i < rest.length() && rest[i] != quote {
out.write_char(rest[i])
i = i + 1
}
if i >= rest.length() || out.to_string().length() == 0 {
return None
}
Some(out.to_string())
} else if is_name_start(rest[i]) {
let out = StringBuilder()
while i < rest.length() && is_name_char(rest[i]) {
out.write_char(rest[i])
i = i + 1
}
Some(out.to_string())
} else {
// `<<<` herestrings and arithmetic shifts open no document.
None
}
}
}
}
///|
/// Scan one startup file: every line that defines an entry becomes a
/// candidate, and the file text comes back with the adopted lines removed —
/// or, where removing one would empty a conditional body or fan out a
/// helper, replaced by a no-op (`:`) so the file stays valid.
///
/// A definition inside a function body belongs to code that runs later and is
/// never touched. Lines inside a managed block are read as candidates too —
/// they let `shrc init` rebuild a manifest that was lost — but they are never
/// removed, because the block is rewritten from the manifest anyway.
pub fn scan_file(family : ShellFamily, text : String) -> FileScan {
let kept = []
let entries = []
let mut retained = 0
let mut in_block = false
// Per-family nesting state: POSIX brace depth (function bodies), POSIX
// conditional depth, POSIX here-document terminator, fish block depths.
let mut braces = 0
let mut blocks = 0
let mut heredoc = ""
let mut fish_depth = 0
let mut fish_functions = 0
for line in split_lines(text) {
if line == block_begin {
in_block = true
kept.push(line)
} else if line == block_end {
in_block = false
kept.push(line)
} else {
let indented = line.has_prefix(" ") || line.has_prefix("\t")
let mut nested = indented
let mut skip = false
match family {
Posix =>
if heredoc != "" {
if line.trim().to_owned() == heredoc {
heredoc = ""
}
kept.push(line)
skip = true
} else {
if line.contains("<<") {
match heredoc_terminator(line) {
Some(term) => heredoc = term
None => ()
}
}
if braces > 0 {
// Inside a function body: never moved.
kept.push(line)
skip = true
}
nested = nested || blocks > 0
blocks = blocks + posix_block_delta(line)
braces = braces + brace_delta(line)
}
Fish => {
let in_function = fish_functions > 0
let (depth_delta, function_delta) = fish_block_delta(
line, fish_depth, fish_functions,
)
fish_depth = fish_depth + depth_delta
fish_functions = fish_functions + function_delta
if in_function {
kept.push(line)
skip = true
}
nested = nested || fish_depth > 0 || fish_functions > 0
}
Csh | Nu | PowerShell =>
if indented {
// These families keep their conditional bodies unparseable enough
// that only a top-level definition is safe to move.
kept.push(line)
skip = true
}
}
if !skip {
match scan_line(family, strip_leading_ws(line)) {
Adopt(entry) => {
entries.push(entry)
if in_block {
kept.push(line)
} else if nested {
kept.push(indent_of(line) + ":")
}
// At the top level the line disappears: the block carries it now.
}
Retain => {
retained = retained + 1
kept.push(line)
}
Ignore => kept.push(line)
}
}
}
}
{ cleaned: join_file_lines(kept), entries, retained, }
}
///|
/// The entry with the same kind and name as `wanted`, if any.
pub fn find_entry(entries : Array[Entry], wanted : Entry) -> Entry? {
for entry in entries {
if kind_eq(entry.kind, wanted.kind) && entry.name == wanted.name {
return Some(entry)
}
}
None
}
///|
/// Whether `items` holds `value`.
fn array_contains(items : Array[String], value : String) -> Bool {
for item in items {
if item == value {
return true
}
}
false
}
///|
/// Drop the shell punctuation a path token may carry (`;` after a source on
/// one line, for example).
fn trim_token_end(token : String) -> String {
let chars = token.to_array()
let mut end = chars.length()
while end > 0 {
let ch = chars[end - 1]
if ch == ';' || ch == '&' || ch == '|' || ch == ')' {
end = end - 1
} else {
break
}
}
token.view(end_offset=end).to_owned()
}
///|
/// Resolve `~/...`, `$HOME/...` or `${HOME}/...` to an absolute path.
fn home_path(token : String) -> String? {
let home = home_dir()
if token.has_prefix("~/") {
Some(path_join(home, token.view(start_offset=2).to_owned()))
} else if token.has_prefix("$HOME/") {
Some(path_join(home, token.view(start_offset=6).to_owned()))
} else if token.has_prefix("${HOME}/") {
Some(path_join(home, token.view(start_offset=8).to_owned()))
} else {
None
}
}
///|
/// The literal text of a path word, or `None` when it is quoted with
/// expansion inside.
fn token_text(word : String) -> String? {
match parse_rhs(word) {
SingleQuoted(value) => Some(value)
DoubleQuoted(inner) => if contains_live(inner) { None } else { Some(inner) }
BareWord(token) => Some(token)
Unparsed => None
}
}
///|
/// The file a `.`/`source` line loads, as a path under the home directory.
/// Only `~` and `$HOME` paths are followed — shrc never walks the system
/// includes — and comments are ignored.
fn source_path_text(line : String) -> String? {
if line.length() == 0 || line.has_prefix("#") {
return None
}
for marker in [". ", "source "] {
match line.find(marker) {
Some(index) => {
let before = if index == 0 { ' ' } else { line.to_array()[index - 1] }
let at_word_start = before == ' ' ||
before == '\t' ||
before == ';' ||
before == '&' ||
before == '(' ||
before == '|'
if at_word_start {
let rest = line.view(start_offset=index + marker.length()).to_owned()
match split_words(rest) {
Some(words) =>
if words.length() > 0 {
match token_text(words[0]) {
Some(token) =>
match home_path(trim_token_end(token)) {
Some(path) => return Some(path)
None => ()
}
None => ()
}
}
None => ()
}
}
}
None => ()
}
}
None
}
///|
/// The files a startup file loads with `.`/`source`, resolved under the home
/// directory. They are scanned like the startup file itself, so definitions
/// kept in a companion like `~/.bash_aliases` are managed too.
pub fn sourced_files(family : ShellFamily, text : String) -> Array[String] {
let out = []
match family {
Nu | PowerShell => return out
Posix | Fish | Csh => ()
}
for line in split_lines(text) {
match source_path_text(strip_leading_ws(line)) {
Some(path) =>
if !array_contains(out, path) && @fs.path_exists(path) {
out.push(path)
}
None => ()
}
}
out
}
///|
/// Merge a file's candidates into `entries`, appending new ones to
/// `imported`. Returns `(duplicates, conflicts)`: a duplicate is a line that
/// repeats an existing value, a conflict a line with a different value for an
/// existing name, which keeps the existing value.
pub fn merge_candidates(
entries : Array[Entry],
imported : Array[Entry],
candidates : Array[Entry],
) -> (Int32, Int32) {
let mut duplicates = 0
let mut conflicts = 0
for candidate in candidates {
// find existing by kind+name
let mut found_index = -1
for i, existing in entries {
if kind_eq(existing.kind, candidate.kind) &&
existing.name == candidate.name {
found_index = i
break
}
}
if found_index >= 0 {
let previous = entries[found_index]
if entry_eq(previous, candidate) {
duplicates = duplicates + 1
} else if previous.imported {
// The startup file is the source of truth for an imported entry:
// adopt the manually edited value instead of keeping the stale one.
entries[found_index] = Entry::new_full(
previous.kind,
previous.name,
candidate.value,
candidate.raw,
candidate.list || previous.list,
imported=true,
)
} else {
conflicts = conflicts + 1
}
} else {
entries.push(candidate)
imported.push(candidate)
}
}
(duplicates, conflicts)
}
///|
/// Replace the definition of `name` in `text` with `replacement` (or remove it
/// when `replacement` is `None`). Returns the new text and whether anything
/// changed. Leading indentation of the original line is preserved.
pub fn edit_definition_in_text(
text : String,
family : ShellFamily,
name : String,
replacement : String?,
) -> (String, Bool) {
let lines = split_lines(text)
let out = []
let mut changed = false
for line in lines {
let scan = scan_line(family, line)
let mut is_target = false
match scan {
Adopt(entry) => if entry.name == name { is_target = true }
_ => ()
}
if is_target {
changed = true
match replacement {
Some(new_text) => {
let indent = leading_indent(line)
out.push(indent + new_text)
}
None => ()
}
} else {
out.push(line)
}
}
let joined = if out.length() == 0 { "" } else { out.join("\n") + "\n" }
(joined, changed)
}
///|
fn leading_indent(text : String) -> String {
let chars = text.to_array()
let mut i = 0
while i < chars.length() && (chars[i] == ' ' || chars[i] == '\t') {
i = i + 1
}
text.view(end_offset=i).to_owned()
}