Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 1 addition & 19 deletions javascript/packages/config/src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
10 changes: 1 addition & 9 deletions javascript/packages/config/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>

export type HerbConfigOptions = {
files?: FilesConfig
Expand Down
1 change: 1 addition & 0 deletions javascript/packages/config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type {
HerbConfigOptions,
LinterConfig,
FormatterConfig,
EngineConfig,
RuleConfig,
FilesConfig,
LoadOptions,
Expand Down
4 changes: 4 additions & 0 deletions javascript/packages/config/src/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export function deepMerge<T extends Record<string, any>>(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]
Expand Down
74 changes: 74 additions & 0 deletions javascript/packages/config/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions javascript/packages/config/test/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
42 changes: 1 addition & 41 deletions rust/herb-config/src/config_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,19 +90,6 @@ pub struct FormatterConfig {
pub rewriter: Option<RewriterConfig>,
}

#[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<bool>,

#[serde(default, skip_serializing_if = "Option::is_none")]
pub nesting: Option<bool>,

#[serde(default, skip_serializing_if = "Option::is_none")]
pub accessibility: Option<bool>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Framework {
Expand All @@ -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<bool>,

#[serde(default, skip_serializing_if = "Option::is_none")]
pub render_nodes: Option<bool>,

#[serde(default, skip_serializing_if = "Option::is_none")]
pub strict_locals: Option<bool>,
}

#[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<bool>,

#[serde(default, skip_serializing_if = "Option::is_none")]
pub debug: Option<bool>,

#[serde(default, skip_serializing_if = "Option::is_none")]
pub parser_options: Option<ParserOptionsConfig>,

#[serde(default, skip_serializing_if = "Option::is_none")]
pub validators: Option<ValidatorsConfig>,
}
pub type EngineConfig = serde_yaml::Mapping;

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
Expand Down
3 changes: 1 addition & 2 deletions rust/herb-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions rust/herb-config/tests/load_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading