diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md index 02ee1a945..8fdf5cf55 100644 --- a/docs/docs/configuration.md +++ b/docs/docs/configuration.md @@ -298,6 +298,8 @@ engine: accessibility: true # Enable/disable accessibility validation (default: true) ``` +The `engine` section is only read by `Herb::Engine` when it compiles templates. The tools that don't compile templates (`herb-lint`, `herb-format`, and the Language Server) pass it through without validating it, so an engine option they don't know about won't make them reject your configuration file. + ### Validators The engine runs validators on templates during compilation. Each validator can be individually enabled or disabled: diff --git a/javascript/packages/config/src/config-schema.ts b/javascript/packages/config/src/config-schema.ts index 2f4209518..34c7c6c6a 100644 --- a/javascript/packages/config/src/config-schema.ts +++ b/javascript/packages/config/src/config-schema.ts @@ -47,31 +47,13 @@ export const FormatterConfigSchema = z.object({ rewriter: RewriterConfigSchema.describe("Rewriter configuration for pre and post-format transformations"), }).strict().optional() -export const ValidatorsConfigSchema = z.object({ - security: z.boolean().optional().describe("Enable or disable the security validator (default: true)"), - nesting: z.boolean().optional().describe("Enable or disable the nesting validator (default: true)"), - accessibility: z.boolean().optional().describe("Enable or disable the accessibility validator (default: true)"), -}).strict().optional() - export const FrameworkSchema = z.enum(["ruby", "actionview", "hanami", "sinatra"]).optional() .describe("Framework context (default: 'ruby')") export const TemplateEngineSchema = z.enum(["erubi", "erb", "herb"]).optional() .describe("Template engine used for compilation (default: 'erubi')") -export const ParserOptionsSchema = z.object({ - strict: z.boolean().optional().describe("Enable strict parsing mode (default: true)"), - render_nodes: z.boolean().optional().describe("Enable render node detection"), - strict_locals: z.boolean().optional().describe("Enable strict locals detection"), - iteration_nodes: z.boolean().optional().describe("Enable each block node detection"), -}).strict().optional() - -export const EngineConfigSchema = z.object({ - optimize: z.boolean().optional().describe("Enable compile-time optimizations (default: false)"), - debug: z.boolean().optional().describe("Enable debug mode (default: false)"), - parser_options: ParserOptionsSchema.describe("Parser options passed through to Herb.parse"), - validators: ValidatorsConfigSchema.describe("Per-validator enable/disable configuration"), -}).strict().optional() +export const EngineConfigSchema = z.record(z.string(), z.unknown()).nullish() export const HerbConfigSchema = z.object({ version: z.string().describe("Configuration file version"), diff --git a/javascript/packages/config/src/config.ts b/javascript/packages/config/src/config.ts index e9035f6c5..afde346e5 100644 --- a/javascript/packages/config/src/config.ts +++ b/javascript/packages/config/src/config.ts @@ -90,15 +90,7 @@ export type FormatterConfig = { } } -export type ValidatorsConfig = { - security?: boolean - nesting?: boolean - accessibility?: boolean -} - -export type EngineConfig = { - validators?: ValidatorsConfig -} +export type EngineConfig = Record export type HerbConfigOptions = { files?: FilesConfig diff --git a/javascript/packages/config/src/index.ts b/javascript/packages/config/src/index.ts index b76bb8e73..29f4aee0e 100644 --- a/javascript/packages/config/src/index.ts +++ b/javascript/packages/config/src/index.ts @@ -7,6 +7,7 @@ export type { HerbConfigOptions, LinterConfig, FormatterConfig, + EngineConfig, RuleConfig, FilesConfig, LoadOptions, diff --git a/javascript/packages/config/src/merge.ts b/javascript/packages/config/src/merge.ts index 5f729039e..1dc0a1146 100644 --- a/javascript/packages/config/src/merge.ts +++ b/javascript/packages/config/src/merge.ts @@ -24,6 +24,10 @@ export function deepMerge>(target: T, source: Deep continue } + if (sourceValue === null && isObject(targetValue)) { + continue + } + if (Array.isArray(sourceValue)) { if ((key === 'include' || key === 'exclude') && Array.isArray(targetValue)) { ;(output as any)[key] = [...targetValue, ...sourceValue] diff --git a/javascript/packages/config/test/config.test.ts b/javascript/packages/config/test/config.test.ts index 589f85d65..0b4d90348 100644 --- a/javascript/packages/config/test/config.test.ts +++ b/javascript/packages/config/test/config.test.ts @@ -1524,6 +1524,80 @@ describe("@herb-tools/config", () => { }) }) + describe("engine configuration", () => { + test("keeps the documented engine options", async () => { + createTestFile(testDir, ".herb.yml", dedent` + version: 0.10.3 + + engine: + optimize: true + debug: true + validators: + security: false + `) + + const config = await Config.load(testDir, { version: "0.10.3", silent: true }) + + expect(config.config.engine).toEqual({ + optimize: true, + debug: true, + validators: { + security: false, + nesting: true, + accessibility: true + } + }) + }) + + test("accepts engine options the JavaScript tools don't know about", async () => { + createTestFile(testDir, ".herb.yml", dedent` + version: 0.10.3 + + engine: + slots: true + parser_options: + timeout: 5 + `) + + const config = await Config.load(testDir, { version: "0.10.3", silent: true }) + + expect(config.config.engine).toMatchObject({ + slots: true, + parser_options: { timeout: 5 } + }) + }) + + test("accepts an empty engine section without dropping the defaults", async () => { + createTestFile(testDir, ".herb.yml", "version: 0.10.3\n\nengine:\n") + + const config = await Config.load(testDir, { version: "0.10.3", silent: true }) + + expect(config.config.engine).toEqual({ + validators: { + security: true, + nesting: true, + accessibility: true + } + }) + }) + + test("accepts an engine section with no options", async () => { + createTestFile(testDir, ".herb.yml", "version: 0.10.3\n\nengine: {}\n") + + const config = await Config.load(testDir, { version: "0.10.3", silent: true }) + + expect(config.isLinterEnabled).toBe(true) + }) + + test("rejects an engine section that isn't a mapping", async () => { + createTestFile(testDir, ".herb.yml", "version: 0.10.3\n\nengine: true\n") + + await expect( + Config.load(testDir, { version: "0.10.3", silent: true }) + ).rejects.toThrow(/at "engine"/) + }) + }) + describe("version skew", () => { const invalidForOlderVersions = dedent` version: 0.10.3 diff --git a/javascript/packages/config/test/merge.test.ts b/javascript/packages/config/test/merge.test.ts index bd498f711..f5e3a4938 100644 --- a/javascript/packages/config/test/merge.test.ts +++ b/javascript/packages/config/test/merge.test.ts @@ -57,6 +57,15 @@ describe("deepMerge", () => { expect(result).toEqual({ a: null, b: 2 }) }) + test("keeps the target object when the source value is null", () => { + const target = { engine: { validators: { security: true } }, b: 2 } + const source = { engine: null } + + const result = deepMerge(target, source) + + expect(result).toEqual({ engine: { validators: { security: true } }, b: 2 }) + }) + test("merges deeply nested objects", () => { const target = { linter: { diff --git a/rust/herb-config/src/config_schema.rs b/rust/herb-config/src/config_schema.rs index 95abcdb95..8143faa15 100644 --- a/rust/herb-config/src/config_schema.rs +++ b/rust/herb-config/src/config_schema.rs @@ -90,19 +90,6 @@ pub struct FormatterConfig { pub rewriter: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ValidatorsConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub security: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub nesting: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub accessibility: Option, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Framework { @@ -120,34 +107,7 @@ pub enum TemplateEngine { Herb, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ParserOptionsConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub strict: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub render_nodes: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub strict_locals: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct EngineConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub optimize: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub debug: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parser_options: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub validators: Option, -} +pub type EngineConfig = serde_yaml::Mapping; #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/rust/herb-config/src/lib.rs b/rust/herb-config/src/lib.rs index bcb3ee5e7..d1b57214d 100644 --- a/rust/herb-config/src/lib.rs +++ b/rust/herb-config/src/lib.rs @@ -14,8 +14,7 @@ mod severity; pub use config::{Config, FoundConfigFile, FromObjectOptions, LoadOptions, SeverityOverridable, Tool, ALL_RULES_KEY, CONFIG_PATH, MISNAMED_CONFIG_PATHS}; pub use config_schema::{ - EngineConfig, FilesConfig, FormatterConfig, Framework, HerbConfig, HerbConfigOptions, LinterConfig, ParserOptionsConfig, RewriterConfig, RuleConfig, - TemplateEngine, ValidatorsConfig, + EngineConfig, FilesConfig, FormatterConfig, Framework, HerbConfig, HerbConfigOptions, LinterConfig, RewriterConfig, RuleConfig, TemplateEngine, }; pub use defaults::DEFAULT_VERSION; diff --git a/rust/herb-config/tests/load_test.rs b/rust/herb-config/tests/load_test.rs index d9b2bb7ef..0746fa5bd 100644 --- a/rust/herb-config/tests/load_test.rs +++ b/rust/herb-config/tests/load_test.rs @@ -71,6 +71,51 @@ fn load_returns_error_for_unknown_keys() { assert!(Config::load(&config_path, None).is_err()); } +#[test] +fn load_accepts_engine_options_it_does_not_know_about() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join(".herb.yml"); + + fs::write( + &config_path, + r#" +version: 0.10.3 +engine: + slots: true + parser_options: + timeout: 5 +"#, + ) + .unwrap(); + + let config = Config::load(dir.path(), None).unwrap(); + let engine = config.config.engine.unwrap(); + + assert_eq!(engine.get("slots").unwrap().as_bool(), Some(true)); + assert_eq!(engine.get("parser_options").unwrap().get("timeout").unwrap().as_u64(), Some(5)); +} + +#[test] +fn load_accepts_an_empty_engine_section() { + let dir = tempfile::tempdir().unwrap(); + + fs::write(dir.path().join(".herb.yml"), "version: 0.10.3\nengine:\n").unwrap(); + + let config = Config::load(dir.path(), None).unwrap(); + + assert!(config.is_linter_enabled()); +} + +#[test] +fn load_returns_error_when_the_engine_section_is_not_a_mapping() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join(".herb.yml"); + + fs::write(&config_path, "engine: true\n").unwrap(); + + assert!(Config::load(&config_path, None).is_err()); +} + #[test] fn load_from_explicit_path_returns_error_when_missing() { assert!(Config::load(Path::new("/nonexistent/.herb.yml"), None).is_err());