refactor(config): parse config values from raw bytes like Zig
Zig parses config values (cli/args.zig, Config.zig field parsers, config/string.zig) from raw byte slices, and each field type decides whether it requires UTF-8 (InvalidValue otherwise) or stores bytes. The port escape-encoded raw bytes into Strings at the line reader and let fields parse the escaped text, so fields could observe encoding artifacts; successive audits kept finding such leaks (RV5-RV8). Field parsers now receive the original bytes: text fields validate UTF-8 where Zig does, byte/path fields store bytes (EncodedOs/OsString). This resolves all 9 RV8 findings (docs/rewrite/notes/review-rv8-codex.md), each with a regression test: Windows path conversion fails with BadPathName instead of substituting U+FFFD; Windows process launch uses strict UTF-8 like Zig's Command; the WTF-8 decoder accepts paired- surrogate encodings like Zig; literal U+FDD0 Unicode key triggers; selection-word-chars and clipboard-codepoint-map reject invalid UTF-8; font style / codepoint-map family names use original bytes; C title actions forward bytes; D-Bus new-window/Flatpak conversions match Zig. Design, sweep and validation: docs/rewrite/notes/final-bytes-refactor.md. Also moves the bundled iTerm2-Color-Schemes license out of resources/themes/ so +list-themes no longer lists it as a theme (output is now byte-identical to Ghostty 1.3.1). Done by Codex (gpt-6-astra), reviewed and verified by the orchestrator. Test Plan: - cargo test --workspace --no-fail-fast (3428 passed, exit 0) - just check-macos (both), just check-windows, just check-wasm - +show-config --default --docs, +list-keybinds, +list-actions --docs, +list-themes --plain compared with /usr/bin/ghostty
This commit is contained in:
1 parent
70c250cf2d
commit
6f9b3129e1
100 files changed
+2310
-1054
No files matched your search
@@ -53,7 +53,7 @@ pub trait ActionEnum: Sized + Copy {
|
||||
|
||||
/// Detect the action from the process CLI args (Zig: `detectArgs`).
|
||||
pub fn detect_args<E: ActionEnum>() -> Result<Option<E>, DetectError> {
|
||||
detect_iter(ghostty_os::args::collect_config_strings())
|
||||
detect_iter(ghostty_os::args::collect_bytes())
|
||||
}
|
||||
|
||||
/// Detect the action from any iterator. Each iterator value should yield
|
||||
@@ -61,7 +61,7 @@ pub fn detect_args<E: ActionEnum>() -> Result<Option<E>, DetectError> {
|
||||
pub fn detect_iter<E: ActionEnum, I, S>(iter: I) -> Result<Option<E>, DetectError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
S: AsRef<[u8]>,
|
||||
{
|
||||
let mut fallback: Option<E> = None;
|
||||
let mut pending: Option<E> = None;
|
||||
@@ -69,7 +69,10 @@ where
|
||||
let arg = arg.as_ref();
|
||||
|
||||
// Allow handling of special cases.
|
||||
if let Some(special) = E::detect_special_case(arg) {
|
||||
if let Some(special) = std::str::from_utf8(arg)
|
||||
.ok()
|
||||
.and_then(E::detect_special_case)
|
||||
{
|
||||
match special {
|
||||
SpecialCase::Action(a) => return Ok(Some(a)),
|
||||
SpecialCase::Fallback(a) => fallback = Some(a),
|
||||
@@ -82,13 +85,18 @@ where
|
||||
}
|
||||
|
||||
// Commands must start with "+"
|
||||
let Some(name) = arg.strip_prefix('+') else {
|
||||
let Some(name) = arg.strip_prefix(b"+") else {
|
||||
continue;
|
||||
};
|
||||
if pending.is_some() {
|
||||
return Err(DetectError::MultipleActions);
|
||||
}
|
||||
pending = Some(E::from_name(name).ok_or(DetectError::InvalidAction)?);
|
||||
pending = Some(
|
||||
std::str::from_utf8(name)
|
||||
.ok()
|
||||
.and_then(E::from_name)
|
||||
.ok_or(DetectError::InvalidAction)?,
|
||||
);
|
||||
}
|
||||
|
||||
// If we have an action, we always return that action, even if we've
|
||||
|
||||
@@ -179,7 +179,7 @@ impl Boo {
|
||||
}
|
||||
|
||||
/// The `boo` command is used to display the animation from the Ghostty website in the terminal
|
||||
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
// Disable on non-desktop systems.
|
||||
if cfg!(not(any(
|
||||
windows,
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn list_reports(dir: &std::path::Path) -> Result<Vec<Report>, Error> {
|
||||
///
|
||||
/// This command currently only supports listing crash reports. Viewing
|
||||
/// and sending crash reports is unimplemented and will be added in the future.
|
||||
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
let mut opts = Options;
|
||||
crate::parse_options(&mut opts, args)?;
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ pub use crate::options::NoOptions as Options;
|
||||
/// This command prefers the `$VISUAL` environment variable over `$EDITOR`,
|
||||
/// if both are set. If neither are set, it will print an error
|
||||
/// and exit.
|
||||
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
let mut stderr = std::io::stderr().lock();
|
||||
|
||||
let mut opts = Options;
|
||||
@@ -78,14 +78,14 @@ fn run_inner(stderr: &mut dyn Write) -> Result<u8, Error> {
|
||||
/// Windows branch of `run`: print the path and fail.
|
||||
#[cfg(windows)]
|
||||
fn write_windows_unsupported(stderr: &mut dyn Write, path: &EncodedOs) -> Result<u8, Error> {
|
||||
write!(
|
||||
stderr,
|
||||
let message = format!(
|
||||
"The `ghostty +edit-config` command is not supported on Windows.\n\
|
||||
Please edit the configuration file manually at the following path:\n\
|
||||
\n\
|
||||
{}\n",
|
||||
path.as_encoded()
|
||||
)?;
|
||||
);
|
||||
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&message))?;
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ impl Action {
|
||||
///
|
||||
/// If the action requests its help text (`--help`/`-h`), the help text
|
||||
/// is printed to stdout and 0 is returned.
|
||||
pub fn run(self, args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(self, args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
match self.run_main(args) {
|
||||
Err(Error::ActionHelpRequested) => {
|
||||
// Future note: for now we just output the help text directly
|
||||
@@ -180,7 +180,7 @@ impl Action {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_main(self, args: &[String]) -> Result<u8, Error> {
|
||||
fn run_main(self, args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
match self {
|
||||
Action::Version => crate::version::run(args),
|
||||
Action::Help => crate::help::run(args),
|
||||
@@ -239,7 +239,7 @@ impl std::fmt::Display for Action {
|
||||
}
|
||||
|
||||
/// Detect the CLI action from `args` (full argv, `argv[0]` included).
|
||||
pub fn detect(args: &[String]) -> Result<Option<Action>, DetectError> {
|
||||
pub fn detect(args: &[impl AsRef<[u8]>]) -> Result<Option<Action>, DetectError> {
|
||||
detect_iter::<Action, _, _>(args.iter())
|
||||
}
|
||||
|
||||
@@ -248,21 +248,21 @@ pub fn detect(args: &[String]) -> Result<Option<Action>, DetectError> {
|
||||
/// reversible escape encoding of `ghostty_os::osbytes`, which the config
|
||||
/// layer (`cli_args`) decodes back to the original bytes.
|
||||
#[must_use]
|
||||
pub fn os_args_to_strings(args: &[std::ffi::OsString]) -> Vec<String> {
|
||||
pub fn os_args_to_bytes(args: &[std::ffi::OsString]) -> Vec<Vec<u8>> {
|
||||
args.iter()
|
||||
.map(|a| ghostty_os::osbytes::os_to_config_string(a))
|
||||
.map(|a| ghostty_os::osbytes::os_bytes(a))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`detect`] over raw OS arguments.
|
||||
pub fn detect_os(args: &[std::ffi::OsString]) -> Result<Option<Action>, DetectError> {
|
||||
detect(&os_args_to_strings(args))
|
||||
detect(&os_args_to_bytes(args))
|
||||
}
|
||||
|
||||
/// [`run_from_args`] over raw OS arguments (byte-preserving).
|
||||
#[must_use]
|
||||
pub fn run_from_os_args(args: &[std::ffi::OsString]) -> Option<u8> {
|
||||
run_from_args(&os_args_to_strings(args))
|
||||
run_from_args(&os_args_to_bytes(args))
|
||||
}
|
||||
|
||||
/// The CLI entrypoint logic of `main_ghostty.zig` for actions: detect the
|
||||
@@ -272,7 +272,7 @@ pub fn run_from_os_args(args: &[std::ffi::OsString]) -> Option<u8> {
|
||||
/// Returns `None` if no action was requested (the caller should launch the
|
||||
/// terminal), or the exit code the process should terminate with.
|
||||
#[must_use]
|
||||
pub fn run_from_args(args: &[String]) -> Option<u8> {
|
||||
pub fn run_from_args(args: &[impl AsRef<[u8]>]) -> Option<u8> {
|
||||
let action = match detect(args) {
|
||||
Ok(a) => a?,
|
||||
Err(err) => {
|
||||
@@ -370,11 +370,8 @@ mod tests {
|
||||
std::ffi::OsString::from("+version"),
|
||||
];
|
||||
assert_eq!(Some(Action::Version), detect_os(&args).unwrap());
|
||||
let strings = os_args_to_strings(&args);
|
||||
assert_eq!(
|
||||
ghostty_os::osbytes::config_str_to_bytes(&strings[1]),
|
||||
b"--title=\xff"
|
||||
);
|
||||
let strings = os_args_to_bytes(&args);
|
||||
assert_eq!(strings[1], b"--title=\xff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,7 +22,7 @@ cli_options!(Options);
|
||||
///
|
||||
/// You can also specify `--help` or `-h` along with any action such as
|
||||
/// `+list-themes` to see help for a specific action.
|
||||
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
let mut opts = Options::default();
|
||||
crate::parse_options(&mut opts, args)?;
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ cli_options!(Options, help);
|
||||
/// Flags:
|
||||
///
|
||||
/// * `--docs`: will print out the documentation for each action.
|
||||
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
let mut opts = Options::default();
|
||||
crate::parse_options(&mut opts, args)?;
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn sorted_colors() -> Vec<(String, (u8, u8, u8))> {
|
||||
///
|
||||
/// * `--plain`: will disable formatting and make the output more
|
||||
/// friendly for Unix tooling. This is default when not printing to a tty.
|
||||
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
let mut opts = Options::default();
|
||||
crate::parse_options(&mut opts, args)?;
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ config_struct! {
|
||||
pub struct Options {
|
||||
/// The font family to search for. If this is set, then only fonts
|
||||
/// matching this family will be listed.
|
||||
"family" => family: Option<String> = None,
|
||||
"family" => family: Option<ghostty_os::osbytes::EncodedOs> = None,
|
||||
|
||||
/// The style name to search for.
|
||||
"style" => style: Option<String> = None,
|
||||
"style" => style: Option<ghostty_os::osbytes::EncodedOs> = None,
|
||||
|
||||
/// Font styles to search for. If this is set, then only fonts that
|
||||
/// match the given styles will be listed.
|
||||
@@ -45,7 +45,7 @@ cli_options!(Options, help);
|
||||
/// * `--family`: Filter results to a specific font family. The family handling
|
||||
/// is identical to the `font-family` set of Ghostty configuration values, so
|
||||
/// this can be used to debug why your desired font may not be loading.
|
||||
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
let mut opts = Options::default();
|
||||
crate::parse_options(&mut opts, args)?;
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ cli_options!(Options, help);
|
||||
///
|
||||
/// * `--plain`: will disable formatting and make the output more
|
||||
/// friendly for Unix tooling. This is default when not printing to a tty.
|
||||
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
let mut opts = Options::default();
|
||||
crate::parse_options(&mut opts, args)?;
|
||||
|
||||
@@ -274,12 +274,13 @@ pub fn collect_bindings(keybinds: &Keybinds) -> (Vec<ChordBinding>, u16, u16) {
|
||||
for (table_name, table_set) in &keybinds.tables {
|
||||
let (mut table_bindings, table_width) = iterate_bindings(table_set);
|
||||
for b in &mut table_bindings {
|
||||
b.table_name = Some(table_name.clone());
|
||||
b.table_name = Some(table_name.to_text_lossy());
|
||||
}
|
||||
|
||||
bindings.extend(table_bindings);
|
||||
widest_chord = widest_chord.max(table_width);
|
||||
widest_table_prefix = widest_table_prefix.max(gwidth(table_name) + gwidth("/"));
|
||||
widest_table_prefix =
|
||||
widest_table_prefix.max(gwidth(&table_name.to_text_lossy()) + gwidth("/"));
|
||||
}
|
||||
|
||||
// std.mem.sort is stable.
|
||||
|
||||
@@ -62,11 +62,21 @@ impl ThemeListElement {
|
||||
/// TODO: use Unicode-aware comparison (as in Zig).
|
||||
#[must_use]
|
||||
pub fn order(&self, other: &ThemeListElement) -> Ordering {
|
||||
let a = self.theme.bytes().map(|c| c.to_ascii_lowercase());
|
||||
let b = other.theme.bytes().map(|c| c.to_ascii_lowercase());
|
||||
let a = config_str_to_bytes(&self.theme)
|
||||
.into_iter()
|
||||
.map(|c| c.to_ascii_lowercase());
|
||||
let b = config_str_to_bytes(&other.theme)
|
||||
.into_iter()
|
||||
.map(|c| c.to_ascii_lowercase());
|
||||
a.cmp(b)
|
||||
}
|
||||
|
||||
/// Plain text for TUI display/search, separate from byte-preserving export.
|
||||
#[must_use]
|
||||
pub fn display_name(&self) -> String {
|
||||
ghostty_os::osbytes::config_str_to_text(&self.theme)
|
||||
}
|
||||
|
||||
/// `file://` URI of the theme file (Zig: `toUri`).
|
||||
#[must_use]
|
||||
pub fn to_uri(&self) -> String {
|
||||
@@ -122,11 +132,11 @@ pub fn collect_themes(resources_dir: Option<&EncodedOs>) -> Vec<ThemeListElement
|
||||
let mut themes = vec![];
|
||||
let mut it = LocationIterator::new(resources_dir);
|
||||
while let Some((location, dir)) = it.next_location() {
|
||||
let rd = match std::fs::read_dir(dir.to_path()) {
|
||||
let rd = match dir.to_path().and_then(std::fs::read_dir) {
|
||||
Ok(rd) => rd,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(e) => {
|
||||
eprintln!("error trying to open {}: {e}", dir.as_encoded());
|
||||
eprintln!("error trying to open {}: {e}", dir.to_text_lossy());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -145,7 +155,7 @@ pub fn collect_themes(resources_dir: Option<&EncodedOs>) -> Vec<ThemeListElement
|
||||
continue;
|
||||
}
|
||||
themes.push(ThemeListElement {
|
||||
path: EncodedOs::from_path(&dir.to_path().join(&raw_name)),
|
||||
path: dir.join(&EncodedOs::from_os(&raw_name)),
|
||||
location,
|
||||
theme: name,
|
||||
rank: None,
|
||||
@@ -170,7 +180,9 @@ pub fn should_include_theme(theme_filter: ColorScheme, theme_config: &Config) ->
|
||||
|
||||
/// Make `path` absolute (`Config::load_file` requires absolute paths).
|
||||
pub(crate) fn absolute_path(path: &EncodedOs) -> EncodedOs {
|
||||
std::path::absolute(path.to_path()).map_or_else(|_| path.clone(), |p| EncodedOs::from_path(&p))
|
||||
path.to_path()
|
||||
.and_then(std::path::absolute)
|
||||
.map_or_else(|_| path.clone(), |p| EncodedOs::from_path(&p))
|
||||
}
|
||||
|
||||
fn resources_dir() -> Option<EncodedOs> {
|
||||
@@ -216,7 +228,7 @@ fn resources_dir() -> Option<EncodedOs> {
|
||||
///
|
||||
/// * `--color`: Specify the color scheme of the themes included in the list.
|
||||
/// This can be `dark`, `light`, or `all`. The default is `all`.
|
||||
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
let mut opts = Options::default();
|
||||
crate::parse_options(&mut opts, args)?;
|
||||
|
||||
@@ -316,12 +328,26 @@ mod tests {
|
||||
assert_eq!(1, themes.len());
|
||||
assert_eq!(config_str_to_bytes(&themes[0].theme), b"th\xffeme".to_vec());
|
||||
// The path points at the real file and the listing loads it.
|
||||
assert_eq!(themes[0].path.to_path(), dir.join(raw));
|
||||
assert_eq!(themes[0].path.to_path().unwrap(), dir.join(raw));
|
||||
let mut out = Vec::new();
|
||||
write_plain(&mut out, &themes, &Options::default()).unwrap();
|
||||
assert_eq!(out, b"th\xffeme (resources)\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_display_and_sort_use_original_names() {
|
||||
let theme = |name: EncodedOs| ThemeListElement {
|
||||
theme: name.into_encoded(),
|
||||
path: EncodedOs::new(),
|
||||
location: Location::Resources,
|
||||
rank: None,
|
||||
};
|
||||
let literal = theme(EncodedOs::from_plain_text("\u{fdd0}é"));
|
||||
let invalid = theme(EncodedOs::from_bytes(b"\xff"));
|
||||
assert_eq!(literal.display_name(), "\u{fdd0}é");
|
||||
assert_eq!(literal.order(&invalid), Ordering::Less);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_sort_and_filter() {
|
||||
let tmp = theme_dir(&[
|
||||
@@ -407,13 +433,9 @@ mod tests {
|
||||
#[test]
|
||||
fn options_color() {
|
||||
let mut opts = Options::default();
|
||||
crate::parse_options(
|
||||
&mut opts,
|
||||
&["g".into(), "--color=dark".into(), "--path".into()],
|
||||
)
|
||||
.unwrap();
|
||||
crate::parse_options(&mut opts, &["g", "--color=dark", "--path"]).unwrap();
|
||||
assert_eq!(ColorScheme::Dark, opts.color);
|
||||
assert!(opts.path);
|
||||
assert!(crate::parse_options(&mut opts, &["g".into(), "--color=blue".into()]).is_err());
|
||||
assert!(crate::parse_options(&mut opts, &["g", "--color=blue"]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -442,7 +442,7 @@ impl Preview {
|
||||
continue;
|
||||
}
|
||||
if searching {
|
||||
theme.rank = rank(&theme.theme, &tokens);
|
||||
theme.rank = rank(&theme.display_name(), &tokens);
|
||||
if theme.rank.is_some() {
|
||||
self.filtered.push(i);
|
||||
}
|
||||
@@ -771,7 +771,7 @@ impl Preview {
|
||||
theme_list.print("\u{276f} ", self.ui_selected(), row, 0);
|
||||
}
|
||||
theme_list.print_ex(
|
||||
&theme.theme,
|
||||
&theme.display_name(),
|
||||
match style {
|
||||
St::Normal => self.ui_standard(),
|
||||
St::Highlighted => self.ui_highlighted(),
|
||||
@@ -783,7 +783,7 @@ impl Preview {
|
||||
None,
|
||||
);
|
||||
if style == St::Selected {
|
||||
let name_len = gw(&theme.theme);
|
||||
let name_len = gw(&theme.display_name());
|
||||
if name_len + 4 < LIST_WIDTH {
|
||||
for i in (2 + name_len)..(LIST_WIDTH - 2) {
|
||||
theme_list.print(" ", self.ui_selected(), row, i);
|
||||
@@ -869,7 +869,7 @@ impl Preview {
|
||||
height,
|
||||
self.ui_standard(),
|
||||
);
|
||||
let theme_line = format!("theme = {}", theme.theme);
|
||||
let theme_line = format!("theme = {}", theme.display_name());
|
||||
let save_instructions: [&str; 14] = [
|
||||
"To apply this theme, add the following line to your Ghostty configuration:",
|
||||
"",
|
||||
@@ -958,7 +958,7 @@ impl Preview {
|
||||
let child = win.child(x_off, 0, width, area.height);
|
||||
child.fill(ui_std);
|
||||
let middle = child.area.height / 2;
|
||||
let text = format!("Unable to open {} from:", theme.theme);
|
||||
let text = format!("Unable to open {} from:", theme.display_name());
|
||||
child.print(
|
||||
&text,
|
||||
ui_err,
|
||||
@@ -966,10 +966,10 @@ impl Preview {
|
||||
(child.area.width / 2).saturating_sub(gw(&text) / 2),
|
||||
);
|
||||
child.print_ex(
|
||||
theme.path.as_encoded(),
|
||||
&theme.path.to_text_lossy(),
|
||||
ui_err,
|
||||
middle,
|
||||
(child.area.width / 2).saturating_sub(gw(theme.path.as_encoded()) / 2),
|
||||
(child.area.width / 2).saturating_sub(gw(&theme.path.to_text_lossy()) / 2),
|
||||
Some(&theme.to_uri()),
|
||||
None,
|
||||
);
|
||||
@@ -1017,18 +1017,18 @@ impl Preview {
|
||||
child.fill(standard);
|
||||
let uri = theme.to_uri();
|
||||
child.print_ex(
|
||||
&theme.theme,
|
||||
&theme.display_name(),
|
||||
standard_bold_italic,
|
||||
1,
|
||||
(child.area.width / 2).saturating_sub(gw(&theme.theme) / 2),
|
||||
(child.area.width / 2).saturating_sub(gw(&theme.display_name()) / 2),
|
||||
Some(&uri),
|
||||
None,
|
||||
);
|
||||
child.print_ex(
|
||||
theme.path.as_encoded(),
|
||||
&theme.path.to_text_lossy(),
|
||||
standard,
|
||||
2,
|
||||
(child.area.width / 2).saturating_sub(gw(theme.path.as_encoded()) / 2),
|
||||
(child.area.width / 2).saturating_sub(gw(&theme.path.to_text_lossy()) / 2),
|
||||
Some(&uri),
|
||||
None,
|
||||
);
|
||||
@@ -1048,7 +1048,7 @@ impl Preview {
|
||||
);
|
||||
for (i, diag) in config.diagnostics.items().iter().enumerate() {
|
||||
child.print(
|
||||
&diag.to_string(),
|
||||
&ghostty_os::osbytes::config_str_to_text(&diag.to_string()),
|
||||
ui_err,
|
||||
2 + u16::try_from(i).unwrap_or(0),
|
||||
2,
|
||||
@@ -1409,7 +1409,7 @@ fn error_name(e: &ghostty_config::config::Error) -> String {
|
||||
pub fn resolve_auto_theme_path() -> Result<std::path::PathBuf, Error> {
|
||||
let main_cfg_path = ghostty_config::file_load::preferred_default_file_path()
|
||||
.map_err(|e| Error::Other(e.to_string()))?
|
||||
.to_path();
|
||||
.to_path()?;
|
||||
let base_dir = main_cfg_path
|
||||
.parent()
|
||||
.ok_or_else(|| Error::Other("BadPathName".to_string()))?;
|
||||
@@ -1957,6 +1957,21 @@ mod tests {
|
||||
assert_eq!(Mode::Normal, p.mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_preview_renders_plain_unicode_names() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut p = app(&tmp);
|
||||
let name = "Alpha\u{fdd0}é";
|
||||
p.themes[0].theme = ghostty_os::osbytes::EncodedOs::from_plain_text(name).into_encoded();
|
||||
render(&mut p, 120, 60);
|
||||
assert!(p.overlays().iter().any(|overlay| overlay.text == name));
|
||||
assert!(
|
||||
!p.overlays()
|
||||
.iter()
|
||||
.any(|overlay| overlay.text.contains("\u{fdd0}\u{fdd0}"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -38,49 +38,58 @@ pub struct Options {
|
||||
|
||||
/// All of the arguments after `+new-window`. They will be sent to Ghostty
|
||||
/// for processing.
|
||||
pub arguments: Vec<String>,
|
||||
}
|
||||
|
||||
fn is_ascii_ws(c: char) -> bool {
|
||||
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
|
||||
pub arguments: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Options {
|
||||
fn check_arg(&mut self, arg: &str) -> Result<Option<String>, ArgError> {
|
||||
if let Some(rest) = arg.strip_prefix("--class=") {
|
||||
self.class = Some(rest.trim_matches(is_ascii_ws).to_string());
|
||||
fn check_arg(&mut self, arg: &[u8]) -> Result<Option<Vec<u8>>, ArgError> {
|
||||
if let Some(rest) = arg.strip_prefix(b"--class=") {
|
||||
self.class = Some(
|
||||
std::str::from_utf8(ghostty_config::cli_args::trim_ascii_whitespace(rest))
|
||||
.map_err(|_| ArgError::InvalidValue)?
|
||||
.to_owned(),
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(rest) = arg.strip_prefix("--working-directory=") {
|
||||
let stripped = rest.trim_matches(is_ascii_ws);
|
||||
if stripped == "home" || stripped == "inherit" {
|
||||
return Ok(Some(arg.to_string()));
|
||||
if let Some(rest) = arg.strip_prefix(b"--working-directory=") {
|
||||
let stripped = ghostty_config::cli_args::trim_ascii_whitespace(rest);
|
||||
if stripped == b"home" || stripped == b"inherit" {
|
||||
return Ok(Some(arg.to_vec()));
|
||||
}
|
||||
// The CLI argument is an encoded OS string.
|
||||
let expanded = ghostty_os::expand_home(&EncodedOs::from_encoded(stripped))
|
||||
// Preserve the original argument bytes while expanding its path.
|
||||
let expanded = ghostty_os::expand_home(&EncodedOs::from_bytes(stripped))
|
||||
.map_err(|e| ArgError::Other(e.to_string()))?;
|
||||
let realpath = std::fs::canonicalize(expanded.to_path())
|
||||
let realpath = expanded
|
||||
.to_path()
|
||||
.and_then(std::fs::canonicalize)
|
||||
.map_err(|e| ArgError::Other(e.to_string()))?;
|
||||
self.working_directory_seen = true;
|
||||
return Ok(Some(format!(
|
||||
"--working-directory={}",
|
||||
EncodedOs::from_path(&realpath).as_encoded()
|
||||
)));
|
||||
return Ok(Some(
|
||||
[
|
||||
b"--working-directory=".as_slice(),
|
||||
&EncodedOs::from_path(&realpath).to_bytes(),
|
||||
]
|
||||
.concat(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Some(arg.to_string()))
|
||||
Ok(Some(arg.to_vec()))
|
||||
}
|
||||
}
|
||||
|
||||
impl CliTarget for Options {
|
||||
fn parse_into_field(&mut self, _key: &str, _value: Option<&str>) -> Result<(), ArgError> {
|
||||
fn parse_into_field(&mut self, _key: &str, _value: Option<&[u8]>) -> Result<(), ArgError> {
|
||||
Err(ArgError::InvalidField)
|
||||
}
|
||||
|
||||
/// Manual parse hook, collect all of the arguments after `+new-window`.
|
||||
fn parse_manually_hook(&mut self, arg: &str, iter: &mut dyn ArgIter) -> Result<bool, ArgError> {
|
||||
let mut e_seen = arg == "-e";
|
||||
fn parse_manually_hook(
|
||||
&mut self,
|
||||
arg: &[u8],
|
||||
iter: &mut dyn ArgIter,
|
||||
) -> Result<bool, ArgError> {
|
||||
let mut e_seen = arg == b"-e";
|
||||
|
||||
// Include the argument that triggered the manual parse hook.
|
||||
if let Some(a) = self.check_arg(arg)? {
|
||||
@@ -93,7 +102,7 @@ impl CliTarget for Options {
|
||||
self.arguments.push(param);
|
||||
continue;
|
||||
}
|
||||
if param == "-e" {
|
||||
if param == b"-e" {
|
||||
e_seen = true;
|
||||
self.arguments.push(param);
|
||||
continue;
|
||||
@@ -177,9 +186,10 @@ impl CliTarget for Options {
|
||||
/// default command.
|
||||
///
|
||||
/// Available since: 1.2.0
|
||||
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
let mut opts = Options::default();
|
||||
let mut iter = ghostty_config::cli_args::ArgsIterator::new(args.iter().skip(1).cloned());
|
||||
let mut iter =
|
||||
ghostty_config::cli_args::ArgsIterator::new(args.iter().skip(1).map(AsRef::as_ref));
|
||||
match ghostty_config::cli_args::parse(&mut opts, &mut iter) {
|
||||
Ok(()) => {}
|
||||
Err(ArgError::Other(n)) if n == crate::error::HELP_ERROR_NAME => {
|
||||
@@ -196,10 +206,11 @@ pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
let wd = std::fs::canonicalize(".")?;
|
||||
opts.arguments.insert(
|
||||
0,
|
||||
format!(
|
||||
"--working-directory={}",
|
||||
ghostty_os::osbytes::path_to_config_string(&wd)
|
||||
),
|
||||
[
|
||||
b"--working-directory=".as_slice(),
|
||||
&EncodedOs::from_path(&wd).to_bytes(),
|
||||
]
|
||||
.concat(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,7 +270,13 @@ mod tests {
|
||||
fn gathers_arguments_and_class() {
|
||||
let o = parse_str("--class=foo.bar --title=hi --command=ls");
|
||||
assert_eq!(Some("foo.bar".to_string()), o.class);
|
||||
assert_eq!(vec!["--title=hi", "--command=ls"], o.arguments);
|
||||
assert_eq!(
|
||||
vec!["--title=hi", "--command=ls"],
|
||||
o.arguments
|
||||
.iter()
|
||||
.map(|v| std::str::from_utf8(v).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(!o.working_directory_seen);
|
||||
}
|
||||
|
||||
@@ -275,6 +292,9 @@ mod tests {
|
||||
"--working-directory=/nonexistent"
|
||||
],
|
||||
o.arguments
|
||||
.iter()
|
||||
.map(|v| std::str::from_utf8(v).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,17 +302,35 @@ mod tests {
|
||||
fn working_directory_is_resolved() {
|
||||
let o = parse_str("--working-directory=/");
|
||||
assert!(o.working_directory_seen);
|
||||
assert_eq!(vec!["--working-directory=/"], o.arguments);
|
||||
assert_eq!(
|
||||
vec!["--working-directory=/"],
|
||||
o.arguments
|
||||
.iter()
|
||||
.map(|v| std::str::from_utf8(v).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
let o = parse_str("--working-directory=home");
|
||||
assert!(!o.working_directory_seen);
|
||||
assert_eq!(vec!["--working-directory=home"], o.arguments);
|
||||
assert_eq!(
|
||||
vec!["--working-directory=home"],
|
||||
o.arguments
|
||||
.iter()
|
||||
.map(|v| std::str::from_utf8(v).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_is_gathered_not_special() {
|
||||
// Like Zig, the manual hook runs before the help check.
|
||||
let o = parse_str("--help");
|
||||
assert_eq!(vec!["--help"], o.arguments);
|
||||
assert_eq!(
|
||||
vec!["--help"],
|
||||
o.arguments
|
||||
.iter()
|
||||
.map(|v| std::str::from_utf8(v).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,9 +15,9 @@ macro_rules! cli_options {
|
||||
fn parse_into_field(
|
||||
&mut self,
|
||||
key: &str,
|
||||
value: ::core::option::Option<&str>,
|
||||
value: ::core::option::Option<&[u8]>,
|
||||
) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> {
|
||||
self.parse_field(key, value)
|
||||
self.parse_field_bytes(key, value)
|
||||
}
|
||||
fn valid_values(&self, key: &str) -> ::core::option::Option<&'static [&'static str]> {
|
||||
Self::valid_values_for(key)
|
||||
@@ -43,9 +43,9 @@ macro_rules! cli_options {
|
||||
fn parse_into_field(
|
||||
&mut self,
|
||||
key: &str,
|
||||
value: ::core::option::Option<&str>,
|
||||
value: ::core::option::Option<&[u8]>,
|
||||
) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> {
|
||||
self.parse_field(key, value)
|
||||
self.parse_field_bytes(key, value)
|
||||
}
|
||||
fn valid_values(&self, key: &str) -> ::core::option::Option<&'static [&'static str]> {
|
||||
Self::valid_values_for(key)
|
||||
@@ -66,9 +66,9 @@ macro_rules! cli_options {
|
||||
fn parse_into_field(
|
||||
&mut self,
|
||||
key: &str,
|
||||
value: ::core::option::Option<&str>,
|
||||
value: ::core::option::Option<&[u8]>,
|
||||
) -> ::core::result::Result<(), ::ghostty_config::cli_args::ArgError> {
|
||||
self.parse_field(key, value)
|
||||
self.parse_field_bytes(key, value)
|
||||
}
|
||||
fn valid_values(&self, key: &str) -> ::core::option::Option<&'static [&'static str]> {
|
||||
Self::valid_values_for(key)
|
||||
@@ -87,8 +87,8 @@ macro_rules! cli_options {
|
||||
/// Parse the CLI arguments `args` (the full argv, including `argv[0]`,
|
||||
/// which is skipped; `+action` arguments are ignored) into `opts`
|
||||
/// (Zig: `args.argsIterator` + `args.parse`).
|
||||
pub fn parse_options<T: CliTarget>(opts: &mut T, args: &[String]) -> Result<(), Error> {
|
||||
let mut iter = ArgsIterator::new(args.iter().skip(1).cloned());
|
||||
pub fn parse_options<T: CliTarget>(opts: &mut T, args: &[impl AsRef<[u8]>]) -> Result<(), Error> {
|
||||
let mut iter = ArgsIterator::new(args.iter().skip(1).map(AsRef::as_ref));
|
||||
cli_args::parse(opts, &mut iter)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -101,7 +101,7 @@ impl CliTarget for NoOptions {
|
||||
fn parse_into_field(
|
||||
&mut self,
|
||||
_key: &str,
|
||||
_value: Option<&str>,
|
||||
_value: Option<&[u8]>,
|
||||
) -> Result<(), cli_args::ArgError> {
|
||||
Err(cli_args::ArgError::InvalidField)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ cli_options!(Options, help);
|
||||
/// * `--docs`: Print the documentation above each option as a comment,
|
||||
/// This is very noisy but is very useful to learn about available
|
||||
/// options, especially paired with `--default`.
|
||||
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
let mut opts = Options::default();
|
||||
crate::parse_options(&mut opts, args)?;
|
||||
|
||||
@@ -113,6 +113,81 @@ mod tests {
|
||||
assert!(s.contains("\nfont-size = 12\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_config_preserves_raw_and_text_fields_on_reload() {
|
||||
use ghostty_config::cli_args::LineIterator;
|
||||
let raw = b"title=x\xff\xef\xb7\x90\xc3\xa9\nfont-style=style\xfe\xef\xb7\x90\nfont-codepoint-map=U+0041=family\xff\nselection-word-chars=\xef\xb7\x90\xc3\xa9\nclipboard-codepoint-map=U+0041=\xef\xb7\x90\xc3\xa9\nkeybind=\xef\xb7\x90=set_surface_title:payload\ncommand-palette-entry=title:\"name\\xff\",action:\"text:payload\\xfe\"\n";
|
||||
let mut config = Config::new_default();
|
||||
config
|
||||
.load_iter(&mut LineIterator::new(raw.as_slice()))
|
||||
.unwrap();
|
||||
assert_eq!(config.diagnostics.items(), []);
|
||||
let output = format_config_bytes(&config, &Options::default()).unwrap();
|
||||
for line in [
|
||||
b"title = x\xff\xef\xb7\x90\xc3\xa9".as_slice(),
|
||||
b"font-style = style\xfe\xef\xb7\x90",
|
||||
b"font-codepoint-map = U+0041=family\xff",
|
||||
b"selection-word-chars = \xef\xb7\x90\xc3\xa9",
|
||||
b"clipboard-codepoint-map = U+0041=\xef\xb7\x90\xc3\xa9",
|
||||
b"keybind = \xef\xb7\x90=set_surface_title:payload",
|
||||
] {
|
||||
assert!(
|
||||
output.split(|b| *b == b'\n').any(|value| value == line),
|
||||
"missing {line:?} in {output:?}"
|
||||
);
|
||||
}
|
||||
let mut reloaded = Config::new_default();
|
||||
reloaded
|
||||
.load_iter(&mut LineIterator::new(output.as_slice()))
|
||||
.unwrap();
|
||||
assert_eq!(reloaded.diagnostics.items(), []);
|
||||
assert_eq!(reloaded.title, config.title);
|
||||
assert_eq!(reloaded.font_style, config.font_style);
|
||||
assert_eq!(reloaded.font_codepoint_map, config.font_codepoint_map);
|
||||
assert_eq!(reloaded.selection_word_chars, config.selection_word_chars);
|
||||
assert_eq!(
|
||||
reloaded.clipboard_codepoint_map,
|
||||
config.clipboard_codepoint_map
|
||||
);
|
||||
let trigger = ghostty_input::binding::Trigger::parse("\u{fdd0}").unwrap();
|
||||
assert_eq!(
|
||||
reloaded
|
||||
.keybind
|
||||
.set
|
||||
.get(trigger)
|
||||
.unwrap()
|
||||
.as_leaf()
|
||||
.unwrap()
|
||||
.action,
|
||||
config
|
||||
.keybind
|
||||
.set
|
||||
.get(trigger)
|
||||
.unwrap()
|
||||
.as_leaf()
|
||||
.unwrap()
|
||||
.action,
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded.command_palette_entry.value.last(),
|
||||
config.command_palette_entry.value.last()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_config_action_escaping_uses_original_bytes() {
|
||||
use ghostty_config::cli_args::LineIterator;
|
||||
let mut config = Config::new_default();
|
||||
config
|
||||
.load_iter(&mut LineIterator::new(
|
||||
b"keybind=a=set_surface_title:payload\xff\xef\xb7\x90".as_slice(),
|
||||
))
|
||||
.unwrap();
|
||||
let output = format_config_bytes(&config, &Options::default()).unwrap();
|
||||
// Zig formats action byte strings with std.zig.stringEscape.
|
||||
assert!(output.ends_with(b"keybind = a=set_surface_title:payload\\xff\\xef\\xb7\\x90\n"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn show_config_round_trips_non_utf8_and_fdd0_paths() {
|
||||
@@ -152,7 +227,7 @@ mod tests {
|
||||
assert!(opts.default);
|
||||
assert!(!opts.changes_only);
|
||||
assert!(matches!(
|
||||
crate::parse_options(&mut opts, &["g".into(), "--help".into()]),
|
||||
crate::parse_options(&mut opts, &["g", "--help"]),
|
||||
Err(Error::ActionHelpRequested)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ cli_options!(Options, help, diagnostics);
|
||||
/// * `--presentation`: If set, force searching for a specific presentation
|
||||
/// style. Valid options are `text` and `emoji`. If unset, the presentation
|
||||
/// style of a codepoint will be inferred from the Unicode standard.
|
||||
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
let mut opts = Options::default();
|
||||
crate::parse_options(&mut opts, args)?;
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ cli_options!(Options, help);
|
||||
/// ghostty +ssh-cache --remove=example.com # Remove host from cache
|
||||
/// ghostty +ssh-cache --clear # Clear entire cache
|
||||
/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period
|
||||
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
let mut opts = Options::default();
|
||||
crate::parse_options(&mut opts, args)?;
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ impl DiskCache {
|
||||
home: None,
|
||||
})
|
||||
.map_err(|_| Error::XdgLookupFailed)?;
|
||||
Ok(state_dir.to_path().join("ssh_cache"))
|
||||
Ok(state_dir.to_path()?.join("ssh_cache"))
|
||||
}
|
||||
|
||||
/// Clear all cache data stored in the disk cache.
|
||||
|
||||
@@ -11,7 +11,7 @@ config_struct! {
|
||||
pub struct Options {
|
||||
/// The path of the config file to validate. If this isn't specified,
|
||||
/// then the default config file paths will be validated.
|
||||
"config-file" => config_file: Option<String> = None,
|
||||
"config-file" => config_file: Option<EncodedOs> = None,
|
||||
}
|
||||
}
|
||||
cli_options!(Options, help);
|
||||
@@ -25,7 +25,7 @@ cli_options!(Options, help);
|
||||
///
|
||||
/// * `--config-file`: can be passed to validate a specific target config file in
|
||||
/// a non-default location
|
||||
pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
let mut opts = Options::default();
|
||||
crate::parse_options(&mut opts, args)?;
|
||||
|
||||
@@ -42,7 +42,7 @@ fn run_inner(opts: &Options, stdout: &mut dyn Write) -> Result<u8, Error> {
|
||||
if let Some(config_path) = &opts.config_file {
|
||||
// Byte-preserving: the option string carries non-UTF-8 bytes via the
|
||||
// reversible `osbytes` escape and the canonical path may too.
|
||||
let abs_path = std::fs::canonicalize(EncodedOs::from_encoded(config_path).to_path())?;
|
||||
let abs_path = std::fs::canonicalize(config_path.to_path()?)?;
|
||||
cfg.load_file(&EncodedOs::from_path(&abs_path))?;
|
||||
cfg.load_recursive_files()?;
|
||||
} else {
|
||||
@@ -83,7 +83,7 @@ mod tests {
|
||||
fn validate_good_file() {
|
||||
let p = write_tmp("good", "font-size = 14\n");
|
||||
let opts = Options {
|
||||
config_file: Some(p),
|
||||
config_file: Some(p.into()),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(0, run_inner(&opts, &mut out).unwrap());
|
||||
@@ -94,7 +94,7 @@ mod tests {
|
||||
fn validate_bad_file_prints_diagnostics() {
|
||||
let p = write_tmp("bad", "not-a-key = 1\nfont-size = abc\n");
|
||||
let opts = Options {
|
||||
config_file: Some(p.clone()),
|
||||
config_file: Some(p.clone().into()),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(1, run_inner(&opts, &mut out).unwrap());
|
||||
@@ -117,7 +117,7 @@ mod tests {
|
||||
let path = tmp.path().join("config");
|
||||
std::fs::write(&path, b"bad\xffkey = 1\n").unwrap();
|
||||
let opts = Options {
|
||||
config_file: Some(path.to_string_lossy().into_owned()),
|
||||
config_file: Some(EncodedOs::from_path(&path)),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(1, run_inner(&opts, &mut out).unwrap());
|
||||
@@ -148,7 +148,7 @@ mod tests {
|
||||
let link = tmp.path().join("link");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
let opts = Options {
|
||||
config_file: Some(link.to_string_lossy().into_owned()),
|
||||
config_file: Some(EncodedOs::from_path(&link)),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(0, run_inner(&opts, &mut out).unwrap());
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
|
||||
/// The `version` command is used to display information about Ghostty. Recognized as
|
||||
/// either `+version` or `--version`.
|
||||
pub fn run(_args: &[String]) -> Result<u8, Error> {
|
||||
pub fn run(_args: &[impl AsRef<[u8]>]) -> Result<u8, Error> {
|
||||
let stdout = std::io::stdout();
|
||||
let tty = stdout.is_terminal();
|
||||
let mut out = std::io::BufWriter::new(stdout.lock());
|
||||
|
||||
Reference in new issue
Block a user