diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1fe789b7..7673db065 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,7 @@ jobs: [[ "$file" == "Cargo.toml" || "$file" == "Cargo.lock" || "$file" =~ ^crates/.*/Cargo\.toml$ ]] && rust_deps_changed=1 [[ "$file" == "rust-toolchain.toml" || "$file" =~ ^\.cargo/ ]] && rust_config_changed=1 [[ "$file" == "pyproject.toml" ]] && pyproject_changed=1 - [[ "$file" == "docs/reference/cli.md" || "$file" == "docs/reference/configuration.md" || "$file" == "prek.schema.json" ]] && generated_from_code_changed=1 + [[ "$file" == "docs/reference/cli.md" || "$file" == "docs/reference/configuration.md" || "$file" == "prek.schema.json" || "$file" == "prek-hooks.schema.json" ]] && generated_from_code_changed=1 [[ "$file" =~ ^docs/ || "$file" == "mkdocs.yml" || "$file" == "pyproject.toml" || "$file" == "uv.lock" || "$file" == ".github/workflows/publish-docs.yml" ]] && docs_related_changed=1 [[ "$file" =~ ^\.github/workflows/.*\.ya?ml$ ]] && workflow_changed=1 [[ "$file" == ".github/workflows/ci.yml" ]] && ci_workflow_changed=1 diff --git a/crates/prek/src/config/hook.rs b/crates/prek/src/config/hook.rs index 7871b5277..ac5990295 100644 --- a/crates/prek/src/config/hook.rs +++ b/crates/prek/src/config/hook.rs @@ -683,6 +683,15 @@ impl TryFrom for ManifestHook { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "snake_case")] #[serde(transparent)] +#[cfg_attr( + feature = "schemars", + derive(schemars::JsonSchema), + schemars(title = ".pre-commit-hooks.yaml"), + schemars(description = "The hook manifest for prek, a git hook manager written in Rust."), + schemars(extend( + "$id" = "https://raw.githubusercontent.com/j178/prek/master/prek-hooks.schema.json" + )), +)] pub(crate) struct Manifest { pub hooks: Vec, } diff --git a/crates/prek/src/schema.rs b/crates/prek/src/schema.rs index aa135721d..b2d482d6b 100644 --- a/crates/prek/src/schema.rs +++ b/crates/prek/src/schema.rs @@ -421,7 +421,7 @@ impl schemars::JsonSchema for Repo { #[cfg(unix)] #[cfg(all(test, feature = "schemars"))] mod _gen { - use crate::config::Config; + use crate::config::{Config, Manifest}; use anyhow::bail; use prek_consts::env_vars::{EnvVars, EnvVarsRead}; use pretty_assertions::StrComparison; @@ -429,6 +429,7 @@ mod _gen { const ROOT_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../"); + #[derive(Clone, Copy)] enum Mode { /// Update the content. Write, @@ -440,12 +441,12 @@ mod _gen { DryRun, } - fn generate() -> String { + fn generate() -> String { let settings = schemars::generate::SchemaSettings::draft07() .with_transform(schemars::transform::RestrictFormats::default()) .with_transform(super::RemoveNullTypes); let generator = schemars::SchemaGenerator::new(settings); - let schema = generator.into_root_schema_for::(); + let schema = generator.into_root_schema_for::(); serde_json::to_string_pretty(&schema).unwrap() + "\n" } @@ -457,49 +458,58 @@ mod _gen { Mode::Check }; - let schema_string = generate(); - let filename = "prek.schema.json"; - let schema_path = PathBuf::from(ROOT_DIR).join(filename); + let schemas = [ + ("prek.schema.json", generate::()), + ("prek-hooks.schema.json", generate::()), + ]; - match mode { - Mode::DryRun => { - anstream::println!("{schema_string}"); - } - Mode::Check => match fs_err::read_to_string(schema_path) { - Ok(current) => { - if current == schema_string { - anstream::println!("Up-to-date: {filename}"); - } else { - let comparison = StrComparison::new(¤t, &schema_string); + for (filename, schema_string) in schemas { + let schema_path = PathBuf::from(ROOT_DIR).join(filename); + + match mode { + Mode::DryRun => { + anstream::println!("{schema_string}"); + } + Mode::Check => match fs_err::read_to_string(schema_path) { + Ok(current) => { + if current == schema_string { + anstream::println!("Up-to-date: {filename}"); + } else { + let comparison = StrComparison::new(¤t, &schema_string); + bail!( + "{filename} changed, please run `mise run generate` to update:\n{comparison}" + ); + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + bail!("{filename} not found, please run `mise run generate` to generate"); + } + Err(err) => { bail!( - "{filename} changed, please run `mise run generate` to update:\n{comparison}" + "{filename} changed, please run `mise run generate` to update:\n{err}" ); } - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - bail!("{filename} not found, please run `mise run generate` to generate"); - } - Err(err) => { - bail!("{filename} changed, please run `mise run generate` to update:\n{err}"); - } - }, - Mode::Write => match fs_err::read_to_string(&schema_path) { - Ok(current) => { - if current == schema_string { - anstream::println!("Up-to-date: {filename}"); - } else { + }, + Mode::Write => match fs_err::read_to_string(&schema_path) { + Ok(current) => { + if current == schema_string { + anstream::println!("Up-to-date: {filename}"); + } else { + anstream::println!("Updating: {filename}"); + fs_err::write(schema_path, schema_string.as_bytes())?; + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { anstream::println!("Updating: {filename}"); fs_err::write(schema_path, schema_string.as_bytes())?; } - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - anstream::println!("Updating: {filename}"); - fs_err::write(schema_path, schema_string.as_bytes())?; - } - Err(err) => { - bail!("{filename} changed, please run `mise run generate` to update:\n{err}"); - } - }, + Err(err) => { + bail!( + "{filename} changed, please run `mise run generate` to update:\n{err}" + ); + } + }, + } } Ok(()) diff --git a/docs/authoring-hooks.md b/docs/authoring-hooks.md index 4ad8c3223..e28cab97f 100644 --- a/docs/authoring-hooks.md +++ b/docs/authoring-hooks.md @@ -40,8 +40,8 @@ each manifest hook: | `shell` | No | Yes | string enum | Run `entry` through a predefined shell adapter (`sh`, `bash`, `pwsh`, `powershell`, or `cmd`). | | `language` | Yes | No | string | Execution environment, for example `python`, `node`, or `system`. | | `alias` | No | No | string | Alternate identifier accepted by `prek run`. | -| `files` | No | No | regex string | Include only matching files. | -| `exclude` | No | No | regex string | Exclude matching files. | +| `files` | No | No | regex string or glob map | Include only matching files. | +| `exclude` | No | No | regex string or glob map | Exclude matching files. | | `types` | No | No | list of strings | Require all listed file type tags. | | `types_or` | No | No | list of strings | Require at least one listed file type tag. | | `exclude_types` | No | No | list of strings | Exclude files with any listed file type tag. | @@ -76,6 +76,10 @@ manifest semantics. For the upstream reference, see: `pass_filenames: n` with a positive integer is also a `prek` extension. Upstream `pre-commit` only accepts a boolean value. + The `{ glob: ... }` mapping form for `files` and `exclude` is a `prek` + extension. Use the regex string form when a manifest must also work with + upstream `pre-commit`. + The `language_version` options map with `request` and `preference` fields is a `prek` extension. Use the string form when a manifest must also work with upstream `pre-commit`. @@ -91,6 +95,22 @@ manifest semantics. For the upstream reference, see: Project configuration-only fields, such as `priority` and `groups`, are not manifest hook fields. +### Editor completion and validation + +`prek` maintains a [`prek-hooks.schema.json`](https://raw.githubusercontent.com/j178/prek/master/prek-hooks.schema.json) +schema for `.pre-commit-hooks.yaml`. The schema stays in the `prek` repository +instead of being registered with SchemaStore, so editors must opt into it +explicitly. + +With YAML Language Server, add this directive at the top of the manifest: + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/j178/prek/master/prek-hooks.schema.json +``` + +This enables completion and validation for both upstream fields and `prek` +extensions such as glob filters, `env`, and `shell`. + Example: ```yaml diff --git a/docs/configuration.md b/docs/configuration.md index 25039e9c0..837ed42b6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -325,4 +325,7 @@ If you want IDE completion / validation, prek publishes a JSON Schema through th That schema tracks what `prek` accepts today, but `prek` also intentionally tolerates unknown keys for forward compatibility. +Hook authors can opt into the separate schema for `.pre-commit-hooks.yaml` described in +[Editor completion and validation](authoring-hooks.md#editor-completion-and-validation). + For every accepted configuration key and hook option, see the [Configuration Reference](reference/configuration.md). For process environment controls, see the [Environment Variable Reference](reference/environment-variables.md). diff --git a/mise.toml b/mise.toml index ad023d667..672dcb3d1 100644 --- a/mise.toml +++ b/mise.toml @@ -48,12 +48,12 @@ run = "cargo test --bin prek cli::_gen::generate_cli_reference -- --exact" env = { PREK_GENERATE = "1" } [tasks.generate-json-schema] -description = "Generate JSON schema" +description = "Generate JSON schemas" run = "cargo test --bin prek --features schemars schema::_gen::generate_json_schema -- --exact" env = { PREK_GENERATE = "1" } [tasks.generate] -description = "Generate CLI reference and JSON schema" +description = "Generate CLI reference and JSON schemas" run = [{ task = "generate-cli-reference" }, { task = "generate-json-schema" }] [tasks.preview-docs] diff --git a/prek-hooks.schema.json b/prek-hooks.schema.json new file mode 100644 index 000000000..3692e19c9 --- /dev/null +++ b/prek-hooks.schema.json @@ -0,0 +1,276 @@ +{ + "$id": "https://raw.githubusercontent.com/j178/prek/master/prek-hooks.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": ".pre-commit-hooks.yaml", + "description": "The hook manifest for prek, a git hook manager written in Rust.", + "type": "array", + "items": { + "$ref": "#/definitions/ManifestHook" + }, + "definitions": { + "ManifestHook": { + "description": "Common hook options.", + "type": "object", + "properties": { + "id": { + "description": "The id of the hook.", + "type": "string" + }, + "name": { + "description": "The name of the hook.", + "type": "string" + }, + "entry": { + "description": "The command to run. It can contain arguments that will not be overridden.", + "type": "string" + }, + "language": { + "description": "The language of the hook. Tells prek how to install and run the hook.", + "$ref": "#/definitions/Language" + }, + "alias": { + "description": "Not documented in the official docs.", + "type": "string" + }, + "files": { + "description": "The pattern of files to run on.", + "$ref": "#/definitions/FilePattern" + }, + "exclude": { + "description": "Exclude files that were matched by `files`.\nDefault is `$^`, which matches nothing.", + "$ref": "#/definitions/FilePattern" + }, + "types": { + "description": "List of file types to run on (AND).\nDefault is `[file]`, which matches all files.", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "types_or": { + "description": "List of file types to run on (OR).\nDefault is `[]`.", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "exclude_types": { + "description": "List of file types to exclude.\nDefault is `[]`.", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "additional_dependencies": { + "description": "Not documented in the official docs.", + "type": "array", + "items": { + "type": "string" + } + }, + "args": { + "description": "Additional arguments to pass to the hook.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Environment variables to set for the hook.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "always_run": { + "description": "This hook will run even if there are no matching files.\nDefault is false.", + "type": "boolean" + }, + "fail_fast": { + "description": "If this hook fails, don't run any more hooks.\nDefault is false.", + "type": "boolean" + }, + "pass_filenames": { + "description": "Append filenames that would be checked to the hook entry as arguments.\nDefault is true.", + "$ref": "#/definitions/PassFilenames" + }, + "description": { + "description": "A description of the hook.", + "type": "string" + }, + "language_version": { + "description": "Select a language version and optionally configure the toolchain source preference.\nDefault is `default`.\nSee .", + "$ref": "#/definitions/LanguageVersion" + }, + "log_file": { + "description": "Write the output of the hook to a file when the hook fails or verbose is enabled.", + "type": "string" + }, + "shell": { + "description": "Run the hook entry through a predefined shell adapter.", + "$ref": "#/definitions/Shell" + }, + "require_serial": { + "description": "This hook will execute using a single process instead of in parallel.\nDefault is false.", + "type": "boolean" + }, + "stages": { + "description": "Select which Git hook stages this hook runs for.\nDefault all stages are selected.\nSee .", + "type": "array", + "items": { + "$ref": "#/definitions/Stage" + }, + "uniqueItems": true + }, + "verbose": { + "description": "Print the output of the hook even if it passes.\nDefault is false.", + "type": "boolean" + }, + "minimum_prek_version": { + "description": "The minimum version of prek required to run this hook.", + "type": "string" + } + }, + "required": [ + "id", + "name", + "entry", + "language" + ] + }, + "Language": { + "type": "string", + "enum": [ + "bun", + "conda", + "coursier", + "dart", + "deno", + "docker", + "docker_image", + "dotnet", + "fail", + "golang", + "haskell", + "julia", + "lua", + "mise", + "node", + "perl", + "php", + "pygrep", + "python", + "r", + "ruby", + "rust", + "script", + "swift", + "system" + ] + }, + "FilePattern": { + "description": "A file pattern, either a regex or glob pattern(s).", + "oneOf": [ + { + "description": "A regular expression pattern.", + "type": "string" + }, + { + "type": "object", + "properties": { + "glob": { + "oneOf": [ + { + "description": "A glob pattern.", + "type": "string" + }, + { + "description": "A list of glob patterns.", + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "glob" + ] + } + ] + }, + "PassFilenames": { + "description": "Whether to pass filenames to the hook. `true` passes all matching filenames (default), `false` passes none, and a positive integer limits each invocation to at most that many filenames.", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "exclusiveMinimum": 0 + } + ] + }, + "LanguageVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "request": { + "description": "A language-specific version request. Defaults to `default`.", + "type": "string" + }, + "preference": { + "description": "Which toolchain sources to prefer or require. Defaults to `managed`.", + "$ref": "#/definitions/ToolchainPreference" + } + }, + "additionalProperties": false + } + ] + }, + "ToolchainPreference": { + "type": "string", + "enum": [ + "only-managed", + "managed", + "system", + "only-system" + ] + }, + "Shell": { + "description": "A predefined shell adapter used to run hook entries as shell source.", + "type": "string", + "enum": [ + "sh", + "bash", + "pwsh", + "powershell", + "cmd" + ] + }, + "Stage": { + "type": "string", + "enum": [ + "manual", + "commit-msg", + "post-checkout", + "post-commit", + "post-merge", + "post-rewrite", + "pre-commit", + "pre-merge-commit", + "pre-push", + "pre-rebase", + "prepare-commit-msg" + ] + } + } +} diff --git a/prek.toml b/prek.toml index 9ef3aa2e0..c224fd7ae 100644 --- a/prek.toml +++ b/prek.toml @@ -48,7 +48,7 @@ rev = "12e63946db2c5cfcc9030fac21c3883ba88c6ed8" # frozen: 0.38.0 hooks = [ { id = "check-metaschema", - files = { glob = "prek.schema.json" }, + files = { glob = ["prek.schema.json", "prek-hooks.schema.json"] }, }, { id = "check-github-workflows" }, {