diff --git a/.changeset/fluffy-grapes-raise.md b/.changeset/fluffy-grapes-raise.md
new file mode 100644
index 00000000..4da577a2
--- /dev/null
+++ b/.changeset/fluffy-grapes-raise.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-yml": minor
+---
+
+feat: add `yml/file-extension` rule
diff --git a/README.md b/README.md
index 12bc6a0e..ce7a2e0e 100644
--- a/README.md
+++ b/README.md
@@ -199,6 +199,7 @@ The rules with the following star :star: are included in the config.
| [yml/block-mapping](https://ota-meshi.github.io/eslint-plugin-yml/rules/block-mapping.html) | require or disallow block style mappings. | :wrench: | | :star: |
| [yml/block-sequence-hyphen-indicator-newline](https://ota-meshi.github.io/eslint-plugin-yml/rules/block-sequence-hyphen-indicator-newline.html) | enforce consistent line breaks after `-` indicator | :wrench: | | :star: |
| [yml/block-sequence](https://ota-meshi.github.io/eslint-plugin-yml/rules/block-sequence.html) | require or disallow block style sequences. | :wrench: | | :star: |
+| [yml/file-extension](https://ota-meshi.github.io/eslint-plugin-yml/rules/file-extension.html) | enforce YAML file extension | | | |
| [yml/indent](https://ota-meshi.github.io/eslint-plugin-yml/rules/indent.html) | enforce consistent indentation | :wrench: | | :star: |
| [yml/key-name-casing](https://ota-meshi.github.io/eslint-plugin-yml/rules/key-name-casing.html) | enforce naming convention to key names | | | |
| [yml/no-empty-document](https://ota-meshi.github.io/eslint-plugin-yml/rules/no-empty-document.html) | disallow empty document | | :star: | :star: |
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 44150882..22a48bee 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -18,6 +18,7 @@ The rules with the following star :star: are included in the `plugin:yml/recomme
| [yml/block-mapping](./block-mapping.md) | require or disallow block style mappings. | :wrench: | | :star: |
| [yml/block-sequence-hyphen-indicator-newline](./block-sequence-hyphen-indicator-newline.md) | enforce consistent line breaks after `-` indicator | :wrench: | | :star: |
| [yml/block-sequence](./block-sequence.md) | require or disallow block style sequences. | :wrench: | | :star: |
+| [yml/file-extension](./file-extension.md) | enforce YAML file extension | | | |
| [yml/indent](./indent.md) | enforce consistent indentation | :wrench: | | :star: |
| [yml/key-name-casing](./key-name-casing.md) | enforce naming convention to key names | | | |
| [yml/no-empty-document](./no-empty-document.md) | disallow empty document | | :star: | :star: |
diff --git a/docs/rules/block-mapping-colon-indicator-newline.md b/docs/rules/block-mapping-colon-indicator-newline.md
index 33b79ac8..51a8083d 100644
--- a/docs/rules/block-mapping-colon-indicator-newline.md
+++ b/docs/rules/block-mapping-colon-indicator-newline.md
@@ -10,7 +10,6 @@ description: "enforce consistent line breaks after `:` indicator"
> enforce consistent line breaks after `:` indicator
- :exclamation: **_This rule has not been released yet._**
-- :gear: This rule is included in .
- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
## :book: Rule Details
diff --git a/docs/rules/file-extension.md b/docs/rules/file-extension.md
new file mode 100644
index 00000000..549e050f
--- /dev/null
+++ b/docs/rules/file-extension.md
@@ -0,0 +1,60 @@
+---
+pageClass: "rule-details"
+sidebarDepth: 0
+title: "yml/file-extension"
+description: "enforce YAML file extension"
+---
+
+# yml/file-extension
+
+> enforce YAML file extension
+
+- :exclamation: **_This rule has not been released yet._**
+
+## :book: Rule Details
+
+This rule aims to enforce YAML file extension.
+
+
+
+
+
+```yaml
+# ✓ GOOD
+# filename is `example.yaml`
+
+# eslint yml/file-extension: 'error'
+```
+
+
+
+
+
+
+
+```yaml
+# ✗ BAD
+# filename is `example.yml`
+
+# eslint yml/file-extension: 'error'
+```
+
+
+
+## :wrench: Options
+
+```yaml
+yml/file-extension:
+ - error
+ - extension: yaml # or 'yml'
+ caseSensitive: true
+```
+
+- `extension` ... The extension you want to enforce. Default is `"yaml"`.
+- `caseSensitive` ... Specify `true` to enforce lowercase extension. Default is `true`.
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/ota-meshi/eslint-plugin-yml/blob/master/src/rules/file-extension.ts)
+- [Test source](https://github.com/ota-meshi/eslint-plugin-yml/blob/master/tests/src/rules/file-extension.ts)
+- [Test fixture sources](https://github.com/ota-meshi/eslint-plugin-yml/tree/master/tests/fixtures/rules/file-extension)
diff --git a/src/rules/file-extension.ts b/src/rules/file-extension.ts
new file mode 100644
index 00000000..e7ff1f2c
--- /dev/null
+++ b/src/rules/file-extension.ts
@@ -0,0 +1,60 @@
+import path from "path";
+import { createRule } from "../utils";
+
+export default createRule("file-extension", {
+ meta: {
+ docs: {
+ description: "enforce YAML file extension",
+ categories: [],
+ extensionRule: false,
+ layout: false,
+ },
+ schema: [
+ {
+ type: "object",
+ properties: {
+ extension: {
+ enum: ["yaml", "yml"],
+ },
+ caseSensitive: {
+ type: "boolean",
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ messages: {
+ unexpected: `Expected extension '{{expected}}' but used extension '{{actual}}'.`,
+ },
+ type: "suggestion",
+ },
+ create(context) {
+ if (!context.parserServices.isYAML) {
+ return {};
+ }
+ const expected: string = context.options[0]?.extension || "yaml";
+ const caseSensitive: string = context.options[0]?.caseSensitive ?? true;
+
+ return {
+ Program(node) {
+ const filename = context.getFilename();
+ const actual = path.extname(filename);
+ if (
+ (caseSensitive ? actual : actual.toLocaleLowerCase()) ===
+ `.${expected}`
+ ) {
+ return;
+ }
+ context.report({
+ node,
+ loc: node.loc.start,
+ messageId: "unexpected",
+ data: {
+ expected: `.${expected}`,
+ actual,
+ },
+ });
+ },
+ };
+ },
+});
diff --git a/src/utils/rules.ts b/src/utils/rules.ts
index 1017b631..a5ef2728 100644
--- a/src/utils/rules.ts
+++ b/src/utils/rules.ts
@@ -4,6 +4,7 @@ import blockMappingQuestionIndicatorNewline from "../rules/block-mapping-questio
import blockMapping from "../rules/block-mapping";
import blockSequenceHyphenIndicatorNewline from "../rules/block-sequence-hyphen-indicator-newline";
import blockSequence from "../rules/block-sequence";
+import fileExtension from "../rules/file-extension";
import flowMappingCurlyNewline from "../rules/flow-mapping-curly-newline";
import flowMappingCurlySpacing from "../rules/flow-mapping-curly-spacing";
import flowSequenceBracketNewline from "../rules/flow-sequence-bracket-newline";
@@ -32,6 +33,7 @@ export const rules = [
blockMapping,
blockSequenceHyphenIndicatorNewline,
blockSequence,
+ fileExtension,
flowMappingCurlyNewline,
flowMappingCurlySpacing,
flowSequenceBracketNewline,
diff --git a/tests/src/rules/file-extension.ts b/tests/src/rules/file-extension.ts
new file mode 100644
index 00000000..4ecb67a6
--- /dev/null
+++ b/tests/src/rules/file-extension.ts
@@ -0,0 +1,58 @@
+import { RuleTester } from "eslint";
+import rule from "../../../src/rules/file-extension";
+
+const tester = new RuleTester({
+ parser: require.resolve("yaml-eslint-parser"),
+ parserOptions: {
+ ecmaVersion: 2020,
+ },
+});
+
+tester.run("file-extension", rule as any, {
+ valid: [
+ {
+ filename: "test.yaml",
+ code: "a: b",
+ },
+ {
+ filename: "test.yml",
+ code: "a: b",
+ options: [{ extension: "yml" }],
+ },
+ {
+ filename: "test.yaml",
+ code: "a: b",
+ options: [{ extension: "yaml" }],
+ },
+ {
+ filename: "test.YAML",
+ code: "a: b",
+ options: [{ extension: "yaml", caseSensitive: false }],
+ },
+ ],
+ invalid: [
+ {
+ filename: "test.yml",
+ code: "a: b",
+ errors: ["Expected extension '.yaml' but used extension '.yml'."],
+ },
+ {
+ filename: "test.yaml",
+ code: "a: b",
+ options: [{ extension: "yml" }],
+ errors: ["Expected extension '.yml' but used extension '.yaml'."],
+ },
+ {
+ filename: "test.yml",
+ code: "a: b",
+ options: [{ extension: "yaml" }],
+ errors: ["Expected extension '.yaml' but used extension '.yml'."],
+ },
+ {
+ filename: "test.YAML",
+ code: "a: b",
+ options: [{ extension: "yaml" }],
+ errors: ["Expected extension '.yaml' but used extension '.YAML'."],
+ },
+ ],
+});
diff --git a/tools/update-docs.ts b/tools/update-docs.ts
index 11f603d8..20212b8f 100644
--- a/tools/update-docs.ts
+++ b/tools/update-docs.ts
@@ -92,7 +92,7 @@ class DocFile {
notes.push("- :warning: This rule was **deprecated**.");
}
} else {
- if (categories) {
+ if (categories && categories.length) {
const presets = [];
// eslint-disable-next-line @typescript-eslint/require-array-sort-compare -- ignore
for (const cat of categories.sort()) {