feat: port GTK version fallbacks, helpgen, bench modes, CLI leftovers
- apprt-gtk: Ghostty's runtime+build-time version gating (at_least) with cargo features adw_1_3/adw_1_4/adw_1_5/gtk_4_16 (default on) and the fallbacks for older libraries: MessageDialog (adw < 1.5), clipboard dialog without SwitchRow, pre-Banner debug warning and child-exited widgets, the old about dialog, GTK 4.16 CSS error source bytes. The minimum stays GTK 4.14 / libadwaita 1.5 (Ghostty's floor). The root build links with -z lazy so version-gated symbols don't keep older libraries from loading. - ghostty-helpgen (port of helpgen.zig) generates CLI action help and keybinding action docs from doc comments at build time, replacing the checked-in help/*.txt; +help and +list-actions --docs stay byte-identical to Ghostty 1.3.1. - ghostty-bench: libc wcwidth mode and separate simd/table and uucode/table code paths like Zig. - ghostty-cli: Windows branches of edit-config, list-colors, list-keybinds and boo; ssh-cache DiskCache flock locking (no-op on Windows, as in Zig) with an exclusivity test. Test Plan: - cargo test --workspace - diff <(cargo run -q -- +list-actions --docs) \ <(ghostty +list-actions --docs)
This commit is contained in:
1 parent
020beb2d75
commit
4787565397
51 files changed
+2426
-1621
No files matched your search
@@ -0,0 +1,218 @@
|
||||
//! Build-time help text generation (ZIG/src/helpgen.zig).
|
||||
//!
|
||||
//! Zig's `helpgen.zig` parses the sources of the CLI actions and of
|
||||
//! `input/Binding.zig` and turns the `///` doc comments into help strings
|
||||
//! that are compiled into the binary. This crate is the Rust counterpart:
|
||||
//! it is used as a build-dependency by the `build.rs` of `ghostty-cli`
|
||||
//! (action help, from the `///` docs of each `pub fn run`) and of
|
||||
//! `ghostty-input` (keybind action docs, from the `///` docs of the
|
||||
//! variants of `enum Action`), which write Rust source tables into
|
||||
//! `OUT_DIR` that are pulled in with `include!`.
|
||||
//!
|
||||
//! The scanner is deliberately line based (like Zig's token based one it
|
||||
//! only looks at doc comment runs and the item that follows them), so it
|
||||
//! needs no parser dependency.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// Returns the text of a doc comment line without the `///` marker, or
|
||||
/// `None` if the line is not a doc comment (`////` is a plain comment in
|
||||
/// Rust but is accepted by Zig; we follow Rust here).
|
||||
fn strip_marker(line: &str) -> Option<&str> {
|
||||
let rest = line.trim_start().strip_prefix("///")?;
|
||||
if rest.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
Some(rest)
|
||||
}
|
||||
|
||||
/// Port of `extractDocComments` + `findCommonPrefix`: removes the `///`
|
||||
/// markers, then removes the common number of leading spaces of all
|
||||
/// non-blank lines. Returns the lines (without line terminators).
|
||||
///
|
||||
/// `comment_lines` are the raw source lines of one contiguous doc comment
|
||||
/// run. Lines that are not doc comments are ignored.
|
||||
#[must_use]
|
||||
pub fn doc_lines(comment_lines: &[&str]) -> Vec<String> {
|
||||
let lines: Vec<&str> = comment_lines
|
||||
.iter()
|
||||
.filter_map(|l| strip_marker(l))
|
||||
.collect();
|
||||
// Blank lines don't participate in the common prefix.
|
||||
let prefix = lines
|
||||
.iter()
|
||||
.filter_map(|l| l.find(|c| c != ' '))
|
||||
.min()
|
||||
.unwrap_or(usize::MAX);
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| l[prefix.min(l.len())..].to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The doc comment lines directly above line `idx` (attributes such as
|
||||
/// `#[cfg(..)]` between the comment and the item are skipped), in source
|
||||
/// order. Empty if there is no doc comment.
|
||||
fn doc_run_above<'a>(lines: &[&'a str], idx: usize) -> Vec<&'a str> {
|
||||
let mut i = idx;
|
||||
while i > 0 && lines[i - 1].trim_start().starts_with("#[") {
|
||||
i -= 1;
|
||||
}
|
||||
let end = i;
|
||||
while i > 0 && strip_marker(lines[i - 1]).is_some() {
|
||||
i -= 1;
|
||||
}
|
||||
lines[i..end].to_vec()
|
||||
}
|
||||
|
||||
/// The doc comment of the `run` function of a CLI action source file
|
||||
/// (Zig: the doc comment preceding `fn run`), joined with `\n` and with
|
||||
/// no trailing newline. `None` if there is no `run` function or it has no
|
||||
/// doc comment.
|
||||
#[must_use]
|
||||
pub fn run_fn_doc(source: &str) -> Option<String> {
|
||||
let lines: Vec<&str> = source.lines().collect();
|
||||
let idx = lines.iter().position(|l| {
|
||||
let l = l.trim_start();
|
||||
l.strip_prefix("pub fn run")
|
||||
.or_else(|| l.strip_prefix("fn run"))
|
||||
.is_some_and(|r| r.starts_with(['(', '<']))
|
||||
})?;
|
||||
let run = doc_run_above(&lines, idx);
|
||||
if run.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(doc_lines(&run).join("\n"))
|
||||
}
|
||||
|
||||
/// The doc comments of the variants of `enum <name>` in `source`, as
|
||||
/// `(variant, doc)` in declaration order. `doc` is the doc lines joined
|
||||
/// with `\n` plus one trailing newline (Zig's multiline literal plus the
|
||||
/// newline the generated table always carried). Variants without a doc
|
||||
/// comment are returned with `None`.
|
||||
#[must_use]
|
||||
pub fn enum_variant_docs(source: &str, name: &str) -> Vec<(String, Option<String>)> {
|
||||
let lines: Vec<&str> = source.lines().collect();
|
||||
let header = format!("enum {name} {{");
|
||||
let Some(start) = lines
|
||||
.iter()
|
||||
.position(|l| (l.starts_with("pub ") && l.contains(&header)) || *l == header)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (i, line) in lines.iter().enumerate().skip(start + 1) {
|
||||
if *line == "}" {
|
||||
break;
|
||||
}
|
||||
// Variants are exactly one indentation level deep.
|
||||
let Some(rest) = line.strip_prefix(" ") else {
|
||||
continue;
|
||||
};
|
||||
if !rest.starts_with(|c: char| c.is_ascii_uppercase()) {
|
||||
continue;
|
||||
}
|
||||
let ident: String = rest
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
|
||||
.collect();
|
||||
let run = doc_run_above(&lines, i);
|
||||
let doc = (!run.is_empty()).then(|| {
|
||||
let mut d = doc_lines(&run).join("\n");
|
||||
d.push('\n');
|
||||
d
|
||||
});
|
||||
out.push((ident, doc));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Generates the body of `ActionKey::docs`: a `match self` expression over
|
||||
/// the given `(variant, doc)` pairs.
|
||||
#[must_use]
|
||||
pub fn keybind_docs_match(variants: &[(String, String)]) -> String {
|
||||
let mut s = String::from("match self {\n");
|
||||
for (v, d) in variants {
|
||||
let _ = writeln!(s, " ActionKey::{v} => {d:?},");
|
||||
}
|
||||
s.push_str("}\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// Generates the body of `Action::help_text`: a `match self` expression
|
||||
/// over `(variant, help)` pairs.
|
||||
#[must_use]
|
||||
pub fn cli_help_match(actions: &[(String, String)]) -> String {
|
||||
let mut s = String::from("match self {\n");
|
||||
for (v, d) in actions {
|
||||
let _ = writeln!(s, " Action::{v} => {d:?},");
|
||||
}
|
||||
s.push_str("}\n");
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strips_marker_and_common_prefix() {
|
||||
let src = ["/// One", "/// two", " /// three"];
|
||||
assert_eq!(doc_lines(&src), ["One", " two", "three"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_do_not_affect_prefix() {
|
||||
let src = ["///", "/// a", "///", "/// b", "/// "];
|
||||
assert_eq!(doc_lines(&src), ["", "a", "", " b", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_fences_and_indented_blocks_are_preserved() {
|
||||
let src = ["/// ```", "/// indented", "/// ```"];
|
||||
assert_eq!(doc_lines(&src), ["```", " indented", "```"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_slashes_is_not_a_doc_comment() {
|
||||
assert_eq!(doc_lines(&["//// nope", "// plain"]), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_doc_is_extracted() {
|
||||
let src =
|
||||
"use x;\n\n/// First.\n///\n/// Second.\n#[must_use]\npub fn run(a: &[String]) {}\n";
|
||||
assert_eq!(run_fn_doc(src).as_deref(), Some("First.\n\nSecond."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_doc_ignores_other_comments_and_missing() {
|
||||
assert_eq!(run_fn_doc("// x\npub fn run() {}"), None);
|
||||
assert_eq!(run_fn_doc("/// a\npub fn runner() {}"), None);
|
||||
assert_eq!(run_fn_doc("fn other() {}"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_variants_are_extracted() {
|
||||
let src = "pub enum Action {\n /// Ignore.\n ///\n /// More.\n Ignore,\n Undoc,\n /// Send.\n ///\n Csi(String),\n /// Multi\n Multi {\n a: u8,\n },\n}\nenum Other {\n /// no\n X,\n}\n";
|
||||
let v = enum_variant_docs(src, "Action");
|
||||
assert_eq!(
|
||||
v,
|
||||
[
|
||||
("Ignore".into(), Some("Ignore.\n\nMore.\n".into())),
|
||||
("Undoc".into(), None),
|
||||
("Csi".into(), Some("Send.\n\n".into())),
|
||||
("Multi".into(), Some("Multi\n".into())),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_match_is_escaped() {
|
||||
let m = keybind_docs_match(&[("A".into(), "x \"q\"\n".into())]);
|
||||
assert_eq!(
|
||||
m,
|
||||
"match self {\n ActionKey::A => \"x \\\"q\\\"\\n\",\n}\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user