Validate actions in docs (#31073)

Adds a validation step to docs preprocessing so that actions referenced
in docs are checked against the list of all registered actions in GPUI.

In order for this to work properly, all of the crates that register
actions had to be importable by the `docs_preprocessor` crate and
actually used (see [this
comment](ec16e70336 (diff-2674caf14ae6d70752ea60c7061232393d84e7f61a52915ace089c30a797a1c3))
for why this is challenging).

In order to accomplish this I have moved the entry point of zed into a
separate stub file named `zed_main.rs` so that `main.rs` is importable
by the `docs_preprocessor` crate, this is kind of gross, but ensures
that all actions that are registered in the application are registered
when checking them in `docs_preprocessor`. An alternative solution
suggested by @mikayla-maki was to separate out all our `::init()`
functions into a lib entry point in the `zed` crate that can be imported
instead, however, this turned out to be a far bigger refactor and is in
my opinion better to do in a follow up PR with significant testing to
ensure no regressions in behavior occur.

Release Notes:

- N/A
This commit is contained in:
Ben Kunkle 2025-06-04 14:18:12 -05:00 committed by GitHub
parent 52770cd3ad
commit 17c3b741ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 216 additions and 48 deletions

26
.github/actions/build_docs/action.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: "Build docs"
description: "Build the docs"
runs:
using: "composite"
steps:
- name: Setup mdBook
uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08 # v2
with:
mdbook-version: "0.4.37"
- name: Cache dependencies
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-provider: "buildjet"
- name: Install Linux dependencies
shell: bash -euxo pipefail {0}
run: ./script/linux
- name: Build book
shell: bash -euxo pipefail {0}
run: |
mkdir -p target/deploy
mdbook build ./docs --dest-dir=../target/deploy/docs/

View file

@ -191,6 +191,27 @@ jobs:
with:
config: ./typos.toml
check_docs:
timeout-minutes: 60
name: Check docs
needs: [job_spec]
if: github.repository_owner == 'zed-industries'
runs-on:
- buildjet-8vcpu-ubuntu-2204
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
clean: false
- name: Configure CI
run: |
mkdir -p ./../.cargo
cp ./.cargo/ci-config.toml ./../.cargo/config.toml
- name: Build docs
uses: ./.github/actions/build_docs
macos_tests:
timeout-minutes: 60
name: (macOS) Run Clippy and tests

View file

@ -9,7 +9,7 @@ jobs:
deploy-docs:
name: Deploy Docs
if: github.repository_owner == 'zed-industries'
runs-on: ubuntu-latest
runs-on: buildjet-16vcpu-ubuntu-2204
steps:
- name: Checkout repo
@ -17,24 +17,11 @@ jobs:
with:
clean: false
- name: Setup mdBook
uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08 # v2
with:
mdbook-version: "0.4.37"
- name: Set up default .cargo/config.toml
run: cp ./.cargo/collab-config.toml ./.cargo/config.toml
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install libxkbcommon-dev libxkbcommon-x11-dev
- name: Build book
run: |
set -euo pipefail
mkdir -p target/deploy
mdbook build ./docs --dest-dir=../target/deploy/docs/
- name: Build docs
uses: ./.github/actions/build_docs
- name: Deploy Docs
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3

3
Cargo.lock generated
View file

@ -4543,6 +4543,8 @@ version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"command_palette",
"gpui",
"mdbook",
"regex",
"serde",
@ -4550,6 +4552,7 @@ dependencies = [
"settings",
"util",
"workspace-hack",
"zed",
]
[[package]]

View file

@ -120,7 +120,7 @@
"ctrl-'": "editor::ToggleSelectedDiffHunks",
"ctrl-\"": "editor::ExpandAllDiffHunks",
"ctrl-i": "editor::ShowSignatureHelp",
"alt-g b": "editor::ToggleGitBlame",
"alt-g b": "git::Blame",
"menu": "editor::OpenContextMenu",
"shift-f10": "editor::OpenContextMenu",
"ctrl-shift-e": "editor::ToggleEditPrediction",

View file

@ -138,7 +138,7 @@
"cmd-;": "editor::ToggleLineNumbers",
"cmd-'": "editor::ToggleSelectedDiffHunks",
"cmd-\"": "editor::ExpandAllDiffHunks",
"cmd-alt-g b": "editor::ToggleGitBlame",
"cmd-alt-g b": "git::Blame",
"cmd-i": "editor::ShowSignatureHelp",
"f9": "editor::ToggleBreakpoint",
"shift-f9": "editor::EditLogBreakpoint",

View file

@ -448,7 +448,7 @@ impl PickerDelegate for CommandPaletteDelegate {
}
}
fn humanize_action_name(name: &str) -> String {
pub fn humanize_action_name(name: &str) -> String {
let capacity = name.len() + name.chars().filter(|c| c.is_uppercase()).count();
let mut result = String::with_capacity(capacity);
for char in name.chars() {

View file

@ -15,6 +15,9 @@ settings.workspace = true
regex.workspace = true
util.workspace = true
workspace-hack.workspace = true
zed.workspace = true
gpui.workspace = true
command_palette.workspace = true
[lints]
workspace = true

View file

@ -5,6 +5,7 @@ use mdbook::book::{Book, Chapter};
use mdbook::preprocess::CmdPreprocessor;
use regex::Regex;
use settings::KeymapFile;
use std::collections::HashSet;
use std::io::{self, Read};
use std::process;
use std::sync::LazyLock;
@ -17,6 +18,8 @@ static KEYMAP_LINUX: LazyLock<KeymapFile> = LazyLock::new(|| {
load_keymap("keymaps/default-linux.json").expect("Failed to load Linux keymap")
});
static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(dump_all_gpui_actions);
pub fn make_app() -> Command {
Command::new("zed-docs-preprocessor")
.about("Preprocesses Zed Docs content to provide rich action & keybinding support and more")
@ -29,6 +32,9 @@ pub fn make_app() -> Command {
fn main() -> Result<()> {
let matches = make_app().get_matches();
// call a zed:: function so everything in `zed` crate is linked and
// all actions in the actual app are registered
zed::stdout_is_a_pty();
if let Some(sub_args) = matches.subcommand_matches("supports") {
handle_supports(sub_args);
@ -39,6 +45,43 @@ fn main() -> Result<()> {
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Error {
ActionNotFound { action_name: String },
DeprecatedActionUsed { used: String, should_be: String },
}
impl Error {
fn new_for_not_found_action(action_name: String) -> Self {
for action in &*ALL_ACTIONS {
for alias in action.deprecated_aliases {
if alias == &action_name {
return Error::DeprecatedActionUsed {
used: action_name.clone(),
should_be: action.name.to_string(),
};
}
}
}
Error::ActionNotFound {
action_name: action_name.to_string(),
}
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::ActionNotFound { action_name } => write!(f, "Action not found: {}", action_name),
Error::DeprecatedActionUsed { used, should_be } => write!(
f,
"Deprecated action used: {} should be {}",
used, should_be
),
}
}
}
fn handle_preprocessing() -> Result<()> {
let mut stdin = io::stdin();
let mut input = String::new();
@ -46,8 +89,19 @@ fn handle_preprocessing() -> Result<()> {
let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?;
template_keybinding(&mut book);
template_action(&mut book);
let mut errors = HashSet::<Error>::new();
template_and_validate_keybindings(&mut book, &mut errors);
template_and_validate_actions(&mut book, &mut errors);
if !errors.is_empty() {
const ANSI_RED: &'static str = "\x1b[31m";
const ANSI_RESET: &'static str = "\x1b[0m";
for error in &errors {
eprintln!("{ANSI_RED}ERROR{ANSI_RESET}: {}", error);
}
return Err(anyhow::anyhow!("Found {} errors in docs", errors.len()));
}
serde_json::to_writer(io::stdout(), &book)?;
@ -66,13 +120,17 @@ fn handle_supports(sub_args: &ArgMatches) -> ! {
}
}
fn template_keybinding(book: &mut Book) {
fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Error>) {
let regex = Regex::new(r"\{#kb (.*?)\}").unwrap();
for_each_chapter_mut(book, |chapter| {
chapter.content = regex
.replace_all(&chapter.content, |caps: &regex::Captures| {
let action = caps[1].trim();
if find_action_by_name(action).is_none() {
errors.insert(Error::new_for_not_found_action(action.to_string()));
return String::new();
}
let macos_binding = find_binding("macos", action).unwrap_or_default();
let linux_binding = find_binding("linux", action).unwrap_or_default();
@ -86,35 +144,30 @@ fn template_keybinding(book: &mut Book) {
});
}
fn template_action(book: &mut Book) {
fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Error>) {
let regex = Regex::new(r"\{#action (.*?)\}").unwrap();
for_each_chapter_mut(book, |chapter| {
chapter.content = regex
.replace_all(&chapter.content, |caps: &regex::Captures| {
let name = caps[1].trim();
let formatted_name = name
.chars()
.enumerate()
.map(|(i, c)| {
if i > 0 && c.is_uppercase() {
format!(" {}", c.to_lowercase())
} else {
c.to_string()
}
})
.collect::<String>()
.trim()
.to_string()
.replace("::", ":");
format!("<code class=\"hljs\">{}</code>", formatted_name)
let Some(action) = find_action_by_name(name) else {
errors.insert(Error::new_for_not_found_action(name.to_string()));
return String::new();
};
format!("<code class=\"hljs\">{}</code>", &action.human_name)
})
.into_owned()
});
}
fn find_action_by_name(name: &str) -> Option<&ActionDef> {
ALL_ACTIONS
.binary_search_by(|action| action.name.cmp(name))
.ok()
.map(|index| &ALL_ACTIONS[index])
}
fn find_binding(os: &str, action: &str) -> Option<String> {
let keymap = match os {
"macos" => &KEYMAP_MACOS,
@ -180,3 +233,25 @@ where
func(chapter);
});
}
#[derive(Debug, serde::Serialize)]
struct ActionDef {
name: &'static str,
human_name: String,
deprecated_aliases: &'static [&'static str],
}
fn dump_all_gpui_actions() -> Vec<ActionDef> {
let mut actions = gpui::generate_list_of_all_registered_actions()
.into_iter()
.map(|action| ActionDef {
name: action.name,
human_name: command_palette::humanize_action_name(action.name),
deprecated_aliases: action.aliases,
})
.collect::<Vec<ActionDef>>();
actions.sort_by_key(|a| a.name);
return actions;
}

View file

@ -288,6 +288,18 @@ impl ActionRegistry {
}
}
/// Generate a list of all the registered actions.
/// Useful for transforming the list of available actions into a
/// format suited for static analysis such as in validating keymaps, or
/// generating documentation.
pub fn generate_list_of_all_registered_actions() -> Vec<MacroActionData> {
let mut actions = Vec::new();
for builder in inventory::iter::<MacroActionBuilder> {
actions.push(builder.0());
}
actions
}
/// Defines and registers unit structs that can be used as actions.
///
/// To use more complex data types as actions, use `impl_actions!`
@ -333,7 +345,6 @@ macro_rules! action_as {
::std::clone::Clone, ::std::default::Default, ::std::fmt::Debug, ::std::cmp::PartialEq,
)]
pub struct $name;
gpui::__impl_action!(
$namespace,
$name,

View file

@ -12,6 +12,10 @@ workspace = true
[[bin]]
name = "zed"
path = "src/zed-main.rs"
[lib]
name = "zed"
path = "src/main.rs"
[dependencies]

View file

@ -163,7 +163,7 @@ fn fail_to_open_window(e: anyhow::Error, _cx: &mut App) {
}
}
fn main() {
pub fn main() {
#[cfg(unix)]
{
let is_root = nix::unistd::geteuid().is_root();
@ -199,6 +199,11 @@ Error: Running Zed as root or via sudo is unsupported.
return;
}
if args.dump_all_actions {
dump_all_gpui_actions();
return;
}
// Set custom data directory.
if let Some(dir) = &args.user_data_dir {
paths::set_custom_data_dir(dir);
@ -213,9 +218,6 @@ Error: Running Zed as root or via sudo is unsupported.
}
}
menu::init();
zed_actions::init();
let file_errors = init_paths();
if !file_errors.is_empty() {
files_not_created_on_launch(file_errors);
@ -356,6 +358,9 @@ Error: Running Zed as root or via sudo is unsupported.
});
app.run(move |cx| {
menu::init();
zed_actions::init();
release_channel::init(app_version, cx);
gpui_tokio::init(cx);
if let Some(app_commit_sha) = app_commit_sha {
@ -1018,7 +1023,7 @@ fn init_paths() -> HashMap<io::ErrorKind, Vec<&'static Path>> {
})
}
fn stdout_is_a_pty() -> bool {
pub fn stdout_is_a_pty() -> bool {
std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_none() && io::stdout().is_terminal()
}
@ -1055,7 +1060,7 @@ struct Args {
#[arg(long, hide = true)]
askpass: Option<String>,
/// Run zed in the foreground, only used on Windows, to match the behavior of the behavior on macOS.
/// Run zed in the foreground, only used on Windows, to match the behavior on macOS.
#[arg(long)]
#[cfg(target_os = "windows")]
#[arg(hide = true)]
@ -1066,6 +1071,9 @@ struct Args {
#[cfg(target_os = "windows")]
#[arg(hide = true)]
dock_action: Option<usize>,
#[arg(long, hide = true)]
dump_all_actions: bool,
}
#[derive(Clone, Debug)]
@ -1278,3 +1286,28 @@ fn watch_languages(fs: Arc<dyn fs::Fs>, languages: Arc<LanguageRegistry>, cx: &m
#[cfg(not(debug_assertions))]
fn watch_languages(_fs: Arc<dyn fs::Fs>, _languages: Arc<LanguageRegistry>, _cx: &mut App) {}
fn dump_all_gpui_actions() {
#[derive(Debug, serde::Serialize)]
struct ActionDef {
name: &'static str,
human_name: String,
aliases: &'static [&'static str],
}
let mut actions = gpui::generate_list_of_all_registered_actions()
.into_iter()
.map(|action| ActionDef {
name: action.name,
human_name: command_palette::humanize_action_name(action.name),
aliases: action.aliases,
})
.collect::<Vec<ActionDef>>();
actions.sort_by_key(|a| a.name);
io::Write::write(
&mut std::io::stdout(),
serde_json::to_string_pretty(&actions).unwrap().as_bytes(),
)
.unwrap();
}

View file

@ -0,0 +1,5 @@
pub fn main() {
// separated out so that the file containing the main function can be imported by other crates,
// while having all gpui resources that are registered in main (primarily actions) initialized
zed::main();
}

View file

@ -120,7 +120,7 @@ or by simply right clicking and selecting `Copy Permalink` with line(s) selected
| {#action git::Branch} | {#kb git::Branch} |
| {#action git::Switch} | {#kb git::Switch} |
| {#action git::CheckoutBranch} | {#kb git::CheckoutBranch} |
| {#action editor::ToggleGitBlame} | {#kb editor::ToggleGitBlame} |
| {#action git::Blame} | {#kb git::Blame} |
| {#action editor::ToggleGitBlameInline} | {#kb editor::ToggleGitBlameInline} |
> Not all actions have default keybindings, but can be bound by [customizing your keymap](./key-bindings.md#user-keymaps).