Fix docs validation to detect unknown keys (#49660)

Closes #ISSUE

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
This commit is contained in:
Ben KunkleandZed Zippy authored and GitHub committed 2026-02-19 20:46:21 -05:00
1 parent 76d8d97998
commit f3a89766b8
32 files changed
+867 -503

No files matched your search

+100 -29
View File
@@ -3,7 +3,7 @@ use mdbook::BookItem;
use mdbook::book::{Book, Chapter};
use mdbook::preprocess::CmdPreprocessor;
use regex::Regex;
use settings::KeymapFile;
use settings::{KeymapFile, SettingsStore};
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::io::{self, Read};
@@ -22,7 +22,7 @@ static KEYMAP_WINDOWS: LazyLock<KeymapFile> = LazyLock::new(|| {
load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap")
});
static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(load_all_actions);
static ALL_ACTIONS: LazyLock<ActionManifest> = LazyLock::new(load_all_actions);
const FRONT_MATTER_COMMENT: &str = "<!-- ZED_META {} -->";
@@ -68,7 +68,7 @@ enum PreprocessorError {
impl PreprocessorError {
fn new_for_not_found_action(action_name: String) -> Self {
for action in &*ALL_ACTIONS {
for action in &ALL_ACTIONS.actions {
for alias in &action.deprecated_aliases {
if alias == action_name.as_str() {
return PreprocessorError::DeprecatedActionUsed {
@@ -256,13 +256,14 @@ fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Preproces
fn find_action_by_name(name: &str) -> Option<&ActionDef> {
ALL_ACTIONS
.actions
.binary_search_by(|action| action.name.as_str().cmp(name))
.ok()
.map(|index| &ALL_ACTIONS[index])
.map(|index| &ALL_ACTIONS.actions[index])
}
fn actions_available() -> bool {
!ALL_ACTIONS.is_empty()
!ALL_ACTIONS.actions.is_empty()
}
fn is_missing_action(name: &str) -> bool {
@@ -290,6 +291,15 @@ fn find_binding(os: &str, action: &str) -> Option<String> {
}
fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
let settings_schema = SettingsStore::json_schema(&Default::default());
let settings_validator = jsonschema::validator_for(&settings_schema)
.expect("failed to compile settings JSON schema");
let keymap_schema =
keymap_schema_for_actions(&ALL_ACTIONS.actions, &ALL_ACTIONS.schema_definitions);
let keymap_validator =
jsonschema::validator_for(&keymap_schema).expect("failed to compile keymap JSON schema");
fn for_each_labeled_code_block_mut(
book: &mut Book,
errors: &mut HashSet<PreprocessorError>,
@@ -378,9 +388,15 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<Pre
snippet_json_fixed.insert(0, '{');
snippet_json_fixed.push_str("\n}");
}
settings::parse_json_with_comments::<settings::SettingsContent>(
&snippet_json_fixed,
)?;
let value =
settings::parse_json_with_comments::<serde_json::Value>(&snippet_json_fixed)?;
let validation_errors: Vec<String> = settings_validator
.iter_errors(&value)
.map(|err| err.to_string())
.collect();
if !validation_errors.is_empty() {
anyhow::bail!("{}", validation_errors.join("\n"));
}
}
"keymap" => {
if !snippet_json_fixed.starts_with('[') || !snippet_json_fixed.ends_with(']') {
@@ -388,21 +404,14 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<Pre
snippet_json_fixed.push_str("\n]");
}
let keymap = settings::KeymapFile::parse(&snippet_json_fixed)
.context("Failed to parse keymap JSON")?;
for section in keymap.sections() {
for (_keystrokes, action) in section.bindings() {
if let Some((action_name, _)) = settings::KeymapFile::parse_action(action)
.map_err(|err| anyhow::format_err!(err))
.context("Failed to parse action")?
{
anyhow::ensure!(
!is_missing_action(action_name),
"Action not found: {}",
action_name
);
}
}
let value =
settings::parse_json_with_comments::<serde_json::Value>(&snippet_json_fixed)?;
let validation_errors: Vec<String> = keymap_validator
.iter_errors(&value)
.map(|err| err.to_string())
.collect();
if !validation_errors.is_empty() {
anyhow::bail!("{}", validation_errors.join("\n"));
}
}
"debug" => {
@@ -505,19 +514,30 @@ where
struct ActionDef {
name: String,
human_name: String,
#[serde(default)]
schema: Option<serde_json::Value>,
deprecated_aliases: Vec<String>,
#[serde(default)]
deprecation_message: Option<String>,
#[serde(rename = "documentation")]
docs: Option<String>,
}
fn load_all_actions() -> Vec<ActionDef> {
#[derive(Debug, serde::Deserialize)]
struct ActionManifest {
actions: Vec<ActionDef>,
#[serde(default)]
schema_definitions: serde_json::Map<String, serde_json::Value>,
}
fn load_all_actions() -> ActionManifest {
let asset_path = concat!(env!("CARGO_MANIFEST_DIR"), "/actions.json");
match std::fs::read_to_string(asset_path) {
Ok(content) => {
let mut actions: Vec<ActionDef> =
let mut manifest: ActionManifest =
serde_json::from_str(&content).expect("Failed to parse actions.json");
actions.sort_by(|a, b| a.name.cmp(&b.name));
actions
manifest.actions.sort_by(|a, b| a.name.cmp(&b.name));
manifest
}
Err(err) => {
if std::env::var("CI").is_ok() {
@@ -527,7 +547,10 @@ fn load_all_actions() -> Vec<ActionDef> {
"Warning: actions.json not found, action validation will be skipped: {}",
err
);
Vec::new()
ActionManifest {
actions: Vec::new(),
schema_definitions: serde_json::Map::new(),
}
}
}
}
@@ -661,7 +684,7 @@ fn title_regex() -> &'static Regex {
}
fn generate_big_table_of_actions() -> String {
let actions = &*ALL_ACTIONS;
let actions = &ALL_ACTIONS.actions;
let mut output = String::new();
let mut actions_sorted = actions.iter().collect::<Vec<_>>();
@@ -710,3 +733,51 @@ fn generate_big_table_of_actions() -> String {
output
}
fn keymap_schema_for_actions(
actions: &[ActionDef],
schema_definitions: &serde_json::Map<String, serde_json::Value>,
) -> serde_json::Value {
let mut generator = KeymapFile::action_schema_generator();
for (name, definition) in schema_definitions {
generator
.definitions_mut()
.insert(name.clone(), definition.clone());
}
let mut action_schemas = Vec::new();
let mut documentation = collections::HashMap::<&str, &str>::default();
let mut deprecations = collections::HashMap::<&str, &str>::default();
let mut deprecation_messages = collections::HashMap::<&str, &str>::default();
for action in actions {
let schema = action
.schema
.as_ref()
.and_then(|v| serde_json::from_value::<schemars::Schema>(v.clone()).ok());
action_schemas.push((action.name.as_str(), schema));
if let Some(doc) = &action.docs {
documentation.insert(action.name.as_str(), doc.as_str());
}
if let Some(msg) = &action.deprecation_message {
deprecation_messages.insert(action.name.as_str(), msg.as_str());
}
for alias in &action.deprecated_aliases {
deprecations.insert(alias.as_str(), action.name.as_str());
let alias_schema = action
.schema
.as_ref()
.and_then(|v| serde_json::from_value::<schemars::Schema>(v.clone()).ok());
action_schemas.push((alias.as_str(), alias_schema));
}
}
KeymapFile::generate_json_schema(
generator,
action_schemas,
&documentation,
&deprecations,
&deprecation_messages,
)
}