
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
257 lines
8 KiB
Rust
257 lines
8 KiB
Rust
use anyhow::Result;
|
|
use clap::{Arg, ArgMatches, Command};
|
|
use mdbook::BookItem;
|
|
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;
|
|
|
|
static KEYMAP_MACOS: LazyLock<KeymapFile> = LazyLock::new(|| {
|
|
load_keymap("keymaps/default-macos.json").expect("Failed to load MacOS keymap")
|
|
});
|
|
|
|
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")
|
|
.subcommand(
|
|
Command::new("supports")
|
|
.arg(Arg::new("renderer").required(true))
|
|
.about("Check whether a renderer is supported by this preprocessor"),
|
|
)
|
|
}
|
|
|
|
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);
|
|
} else {
|
|
handle_preprocessing()?;
|
|
}
|
|
|
|
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();
|
|
stdin.read_to_string(&mut input)?;
|
|
|
|
let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?;
|
|
|
|
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)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_supports(sub_args: &ArgMatches) -> ! {
|
|
let renderer = sub_args
|
|
.get_one::<String>("renderer")
|
|
.expect("Required argument");
|
|
let supported = renderer != "not-supported";
|
|
if supported {
|
|
process::exit(0);
|
|
} else {
|
|
process::exit(1);
|
|
}
|
|
}
|
|
|
|
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: ®ex::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();
|
|
|
|
if macos_binding.is_empty() && linux_binding.is_empty() {
|
|
return "<div>No default binding</div>".to_string();
|
|
}
|
|
|
|
format!("<kbd class=\"keybinding\">{macos_binding}|{linux_binding}</kbd>")
|
|
})
|
|
.into_owned()
|
|
});
|
|
}
|
|
|
|
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: ®ex::Captures| {
|
|
let name = caps[1].trim();
|
|
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,
|
|
"linux" => &KEYMAP_LINUX,
|
|
_ => unreachable!("Not a valid OS: {}", os),
|
|
};
|
|
|
|
// Find the binding in reverse order, as the last binding takes precedence.
|
|
keymap.sections().rev().find_map(|section| {
|
|
section.bindings().rev().find_map(|(keystroke, a)| {
|
|
if name_for_action(a.to_string()) == action {
|
|
Some(keystroke.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
/// Removes any configurable options from the stringified action if existing,
|
|
/// ensuring that only the actual action name is returned. If the action consists
|
|
/// only of a string and nothing else, the string is returned as-is.
|
|
///
|
|
/// Example:
|
|
///
|
|
/// This will return the action name unmodified.
|
|
///
|
|
/// ```
|
|
/// let action_as_str = "assistant::Assist";
|
|
/// let action_name = name_for_action(action_as_str);
|
|
/// assert_eq!(action_name, "assistant::Assist");
|
|
/// ```
|
|
///
|
|
/// This will return the action name with any trailing options removed.
|
|
///
|
|
///
|
|
/// ```
|
|
/// let action_as_str = "\"editor::ToggleComments\", {\"advance_downwards\":false}";
|
|
/// let action_name = name_for_action(action_as_str);
|
|
/// assert_eq!(action_name, "editor::ToggleComments");
|
|
/// ```
|
|
fn name_for_action(action_as_str: String) -> String {
|
|
action_as_str
|
|
.split(",")
|
|
.next()
|
|
.map(|name| name.trim_matches('"').to_string())
|
|
.unwrap_or(action_as_str)
|
|
}
|
|
|
|
fn load_keymap(asset_path: &str) -> Result<KeymapFile> {
|
|
let content = util::asset_str::<settings::SettingsAssets>(asset_path);
|
|
KeymapFile::parse(content.as_ref())
|
|
}
|
|
|
|
fn for_each_chapter_mut<F>(book: &mut Book, mut func: F)
|
|
where
|
|
F: FnMut(&mut Chapter),
|
|
{
|
|
book.for_each_mut(|item| {
|
|
let BookItem::Chapter(chapter) = item else {
|
|
return;
|
|
};
|
|
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;
|
|
}
|