mdlint 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +181 -0
- data/Rakefile +8 -0
- data/exe/mdlint +7 -0
- data/lib/mdlint/cli.rb +206 -0
- data/lib/mdlint/config.rb +103 -0
- data/lib/mdlint/linter/rule.rb +66 -0
- data/lib/mdlint/linter/rule_engine.rb +48 -0
- data/lib/mdlint/linter/rules/first_line_heading.rb +41 -0
- data/lib/mdlint/linter/rules/heading_increment.rb +36 -0
- data/lib/mdlint/linter/rules/heading_style.rb +31 -0
- data/lib/mdlint/linter/rules/no_multiple_blanks.rb +50 -0
- data/lib/mdlint/linter/rules/no_trailing_spaces.rb +38 -0
- data/lib/mdlint/linter/violation.rb +35 -0
- data/lib/mdlint/linter.rb +28 -0
- data/lib/mdlint/parser/block_parser.rb +585 -0
- data/lib/mdlint/parser/inline_parser.rb +258 -0
- data/lib/mdlint/parser/state.rb +62 -0
- data/lib/mdlint/parser.rb +29 -0
- data/lib/mdlint/renderer/md_renderer.rb +458 -0
- data/lib/mdlint/renderer.rb +13 -0
- data/lib/mdlint/token.rb +65 -0
- data/lib/mdlint/version.rb +5 -0
- data/lib/mdlint.rb +43 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1f620c8c00a85c39a41cc1f81a4708ead9496071dd6cf984cd64eb7749bf646f
|
|
4
|
+
data.tar.gz: 76c8f42560a6b98b8007b05911825017d34cc09dad59ee0545f2a87b863bfd01
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 202ea22fc2617cce04c03a0785ee64dfd64e62e306de58a6cf50257668b0992b2de1fa953cbe8d7374db4de0aeb99940c8dd98e936b34b47022316058a34f8cd
|
|
7
|
+
data.tar.gz: b987544c415e6bfa021d7472080284c07a9f179b1ad46dddcc53944faa4e14a3f3a27db7202f72d8208deac05a95234a8f9b1eab5b3d40907cbd98aa0210eee0
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## Unreleased
|
|
9
|
+
|
|
10
|
+
## 0.1.0 - 2025-01-23
|
|
11
|
+
|
|
12
|
+
- Initial release
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<h1 align="center">mdlint</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="https://img.shields.io/badge/ruby-%3E%3D%203.2-ruby.svg" alt="Ruby Version"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"><a href="https://github.com/ydah/mdlint/actions/workflows/main.yml"><img src="https://github.com/ydah/mdlint/actions/workflows/main.yml/badge.svg?branch=main" alt="CI"></a><a href="https://badge.fury.io/rb/mdlint"><img src="https://badge.fury.io/rb/mdlint.svg" alt="Gem Version"></a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
A pure Ruby Markdown linter and formatter with zero external dependencies
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="#features">Features</a> •
|
|
13
|
+
<a href="#quickstart">Quickstart</a> •
|
|
14
|
+
<a href="#installation">Installation</a> •
|
|
15
|
+
<a href="#usage">Usage</a> •
|
|
16
|
+
<a href="#configuration">Configuration</a> •
|
|
17
|
+
<a href="#lint-rules">Lint Rules</a> •
|
|
18
|
+
<a href="#formatting-style">Formatting</a>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- Pure Ruby, no C/Rust extensions or external dependencies
|
|
24
|
+
- Linting with configurable rules and clear violations
|
|
25
|
+
- Auto-formatting that follows mdformat conventions
|
|
26
|
+
- CLI for CI-friendly workflows and local formatting
|
|
27
|
+
- Simple YAML configuration file
|
|
28
|
+
|
|
29
|
+
## Quickstart
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
gem install mdlint
|
|
33
|
+
|
|
34
|
+
# format in place
|
|
35
|
+
mdlint README.md docs/
|
|
36
|
+
|
|
37
|
+
# check-only (CI)
|
|
38
|
+
mdlint --check README.md
|
|
39
|
+
|
|
40
|
+
# show diffs
|
|
41
|
+
mdlint --diff README.md
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
With Bundler:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bundle add mdlint
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Without Bundler:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
gem install mdlint
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
Command line:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
mdlint README.md docs/
|
|
64
|
+
mdlint --check README.md
|
|
65
|
+
mdlint --diff README.md
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Options:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
Usage: mdlint [options] [paths...]
|
|
72
|
+
|
|
73
|
+
Options:
|
|
74
|
+
-c, --check Check if files are formatted, exit with error if not
|
|
75
|
+
-d, --diff Show diff of changes
|
|
76
|
+
-q, --quiet Suppress output
|
|
77
|
+
-e, --exclude PATTERN Exclude files matching pattern
|
|
78
|
+
-w, --wrap MODE Paragraph wrapping: keep (default), no, or INTEGER
|
|
79
|
+
--number Use consecutive numbering for ordered lists
|
|
80
|
+
--end-of-line MODE End of line: lf (default), crlf, keep
|
|
81
|
+
-v, --version Show version
|
|
82
|
+
-h, --help Show help
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Ruby API:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
require "mdlint"
|
|
89
|
+
|
|
90
|
+
formatted = Mdlint.format("# Hello World")
|
|
91
|
+
Mdlint.format_file("README.md")
|
|
92
|
+
|
|
93
|
+
tokens = Mdlint.parse("# Hello World")
|
|
94
|
+
|
|
95
|
+
violations = Mdlint.lint("# Heading\n\n\n\nParagraph")
|
|
96
|
+
violations.each { |v| puts v }
|
|
97
|
+
|
|
98
|
+
violations = Mdlint.lint_file("README.md")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Configuration
|
|
102
|
+
|
|
103
|
+
Create a `.mdlint.yml` file in your project root:
|
|
104
|
+
|
|
105
|
+
```yaml
|
|
106
|
+
# Check mode (don't modify files)
|
|
107
|
+
check: false
|
|
108
|
+
|
|
109
|
+
# Quiet mode (suppress output)
|
|
110
|
+
quiet: false
|
|
111
|
+
|
|
112
|
+
# Exclude patterns
|
|
113
|
+
exclude:
|
|
114
|
+
- "vendor/**/*.md"
|
|
115
|
+
- "node_modules/**/*.md"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Lint Rules
|
|
119
|
+
|
|
120
|
+
Key rules:
|
|
121
|
+
|
|
122
|
+
- Heading levels should increment by one
|
|
123
|
+
- Heading style should be consistent (ATX)
|
|
124
|
+
- No trailing spaces
|
|
125
|
+
- No multiple consecutive blank lines
|
|
126
|
+
- First line should be a top-level heading
|
|
127
|
+
|
|
128
|
+
## Formatting Style
|
|
129
|
+
|
|
130
|
+
| Element | Style |
|
|
131
|
+
|---------|-------|
|
|
132
|
+
| Headings | ATX style only (`#`) |
|
|
133
|
+
| Bullet lists | Hyphen (`-`), alternating for nested |
|
|
134
|
+
| Ordered lists | All items use `1.` (minimizes diffs) |
|
|
135
|
+
| Code blocks | Fenced style (`` ``` ``) |
|
|
136
|
+
| Horizontal rules | 70 underscores |
|
|
137
|
+
| Hard breaks | Backslash (`\`) |
|
|
138
|
+
| Line endings | LF (configurable) |
|
|
139
|
+
| Reference links | Converted to inline links |
|
|
140
|
+
|
|
141
|
+
## Supported Markdown Elements
|
|
142
|
+
|
|
143
|
+
Block elements:
|
|
144
|
+
|
|
145
|
+
- ATX headings (`# Heading`)
|
|
146
|
+
- Setext headings (converted to ATX)
|
|
147
|
+
- Paragraphs
|
|
148
|
+
- Bullet lists (`-`, `*`, `+`)
|
|
149
|
+
- Ordered lists (`1.`, `2.`)
|
|
150
|
+
- Blockquotes (`>`)
|
|
151
|
+
- Fenced code blocks (`` ``` ``)
|
|
152
|
+
- Indented code blocks (converted to fenced)
|
|
153
|
+
- Horizontal rules (`---`, `***`, `___`)
|
|
154
|
+
- HTML blocks
|
|
155
|
+
- Reference definitions
|
|
156
|
+
|
|
157
|
+
Inline elements:
|
|
158
|
+
|
|
159
|
+
- Bold (`**text**`, `__text__`)
|
|
160
|
+
- Italic (`*text*`, `_text_`)
|
|
161
|
+
- Inline code (`` `code` ``)
|
|
162
|
+
- Links (`[text](url)`)
|
|
163
|
+
- Reference links (`[text][ref]`)
|
|
164
|
+
- Images (``)
|
|
165
|
+
- Autolinks (`<https://example.com>`)
|
|
166
|
+
- Hard breaks (backslash + newline)
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
bin/setup
|
|
172
|
+
bundle exec rspec
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Contributing
|
|
176
|
+
|
|
177
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/ydah/mdlint.
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
MIT License. See `LICENSE.txt` for details.
|
data/Rakefile
ADDED
data/exe/mdlint
ADDED
data/lib/mdlint/cli.rb
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require_relative "config"
|
|
6
|
+
|
|
7
|
+
module Mdlint
|
|
8
|
+
class CLI
|
|
9
|
+
def initialize(argv)
|
|
10
|
+
@argv = argv.dup
|
|
11
|
+
@cli_options = { exclude: [] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run
|
|
15
|
+
parse_options
|
|
16
|
+
load_config
|
|
17
|
+
paths = @argv
|
|
18
|
+
|
|
19
|
+
if paths.empty?
|
|
20
|
+
process_stdin
|
|
21
|
+
else
|
|
22
|
+
process_paths(paths)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def load_config
|
|
29
|
+
config = Config.new
|
|
30
|
+
config.load
|
|
31
|
+
@options = config.merge(@cli_options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_options
|
|
35
|
+
parser = OptionParser.new do |opts|
|
|
36
|
+
opts.banner = "Usage: mdlint [options] [paths...]"
|
|
37
|
+
opts.separator ""
|
|
38
|
+
opts.separator "Options:"
|
|
39
|
+
|
|
40
|
+
opts.on("-c", "--check", "Check if files are formatted, exit with error if not") do
|
|
41
|
+
@cli_options[:check] = true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
opts.on("-d", "--diff", "Show diff of changes") do
|
|
45
|
+
@cli_options[:diff] = true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
opts.on("-q", "--quiet", "Suppress output") do
|
|
49
|
+
@cli_options[:quiet] = true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
opts.on("-e", "--exclude PATTERN", "Exclude files matching pattern") do |pattern|
|
|
53
|
+
@cli_options[:exclude] << pattern
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
opts.on("-w", "--wrap MODE", "Paragraph wrapping mode: keep (default), no, or INTEGER") do |mode|
|
|
57
|
+
@cli_options[:wrap] = parse_wrap_mode(mode)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
opts.on("--number", "Use consecutive numbering for ordered lists") do
|
|
61
|
+
@cli_options[:number] = true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
opts.on("--end-of-line MODE", "End of line: lf (default), crlf, keep") do |mode|
|
|
65
|
+
@cli_options[:end_of_line] = mode.downcase.to_sym
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
opts.on("-v", "--version", "Show version") do
|
|
69
|
+
puts "mdlint #{Mdlint::VERSION}"
|
|
70
|
+
exit 0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
opts.on("-h", "--help", "Show this help") do
|
|
74
|
+
puts opts
|
|
75
|
+
exit 0
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
parser.parse!(@argv)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def process_stdin
|
|
83
|
+
input = $stdin.read
|
|
84
|
+
output = Mdlint.format(input, format_options)
|
|
85
|
+
|
|
86
|
+
if @options[:check]
|
|
87
|
+
exit(input == output ? 0 : 1)
|
|
88
|
+
elsif @options[:diff]
|
|
89
|
+
show_diff("stdin", input, output)
|
|
90
|
+
exit(input == output ? 0 : 1)
|
|
91
|
+
else
|
|
92
|
+
print output
|
|
93
|
+
0
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def process_paths(paths)
|
|
98
|
+
files = collect_files(paths)
|
|
99
|
+
changed_files = []
|
|
100
|
+
|
|
101
|
+
files.each do |file|
|
|
102
|
+
result = process_file(file)
|
|
103
|
+
changed_files << file if result
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if @options[:check]
|
|
107
|
+
exit(changed_files.empty? ? 0 : 1)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
unless @options[:quiet]
|
|
111
|
+
if changed_files.any?
|
|
112
|
+
puts "#{changed_files.length} file(s) reformatted" unless @options[:diff]
|
|
113
|
+
else
|
|
114
|
+
puts "All files are formatted correctly" unless @options[:diff]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
changed_files.empty? ? 0 : 1
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def collect_files(paths)
|
|
122
|
+
files = []
|
|
123
|
+
|
|
124
|
+
paths.each do |path|
|
|
125
|
+
if File.directory?(path)
|
|
126
|
+
Dir.glob(File.join(path, "**", "*.md")).each do |file|
|
|
127
|
+
files << file unless excluded?(file)
|
|
128
|
+
end
|
|
129
|
+
elsif File.file?(path)
|
|
130
|
+
files << path unless excluded?(path)
|
|
131
|
+
else
|
|
132
|
+
warn "Warning: #{path} does not exist"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
files.sort
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def excluded?(file)
|
|
140
|
+
@options[:exclude].any? do |pattern|
|
|
141
|
+
File.fnmatch?(pattern, file, File::FNM_PATHNAME)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def process_file(file)
|
|
146
|
+
original = File.read(file)
|
|
147
|
+
formatted = Mdlint.format(original, format_options)
|
|
148
|
+
changed = original != formatted
|
|
149
|
+
|
|
150
|
+
if changed
|
|
151
|
+
if @options[:diff]
|
|
152
|
+
show_diff(file, original, formatted)
|
|
153
|
+
elsif !@options[:check]
|
|
154
|
+
File.write(file, formatted)
|
|
155
|
+
puts "Reformatted: #{file}" unless @options[:quiet]
|
|
156
|
+
elsif !@options[:quiet]
|
|
157
|
+
puts "Would reformat: #{file}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
changed
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def show_diff(filename, original, formatted)
|
|
165
|
+
require "tempfile"
|
|
166
|
+
|
|
167
|
+
Tempfile.create("mdlint-original") do |orig_file|
|
|
168
|
+
Tempfile.create("mdlint-formatted") do |fmt_file|
|
|
169
|
+
orig_file.write(original)
|
|
170
|
+
orig_file.flush
|
|
171
|
+
fmt_file.write(formatted)
|
|
172
|
+
fmt_file.flush
|
|
173
|
+
|
|
174
|
+
diff_output = `diff -u "#{orig_file.path}" "#{fmt_file.path}" 2>&1`
|
|
175
|
+
unless diff_output.empty?
|
|
176
|
+
diff_output = diff_output.gsub(orig_file.path, "#{filename} (original)")
|
|
177
|
+
.gsub(fmt_file.path, "#{filename} (formatted)")
|
|
178
|
+
puts diff_output
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def parse_wrap_mode(mode)
|
|
185
|
+
case mode.downcase
|
|
186
|
+
when "keep"
|
|
187
|
+
:keep
|
|
188
|
+
when "no"
|
|
189
|
+
:no
|
|
190
|
+
else
|
|
191
|
+
Integer(mode)
|
|
192
|
+
end
|
|
193
|
+
rescue ArgumentError
|
|
194
|
+
warn "Invalid wrap mode: #{mode}. Using 'keep'."
|
|
195
|
+
:keep
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def format_options
|
|
199
|
+
{
|
|
200
|
+
wrap: @options[:wrap] || :keep,
|
|
201
|
+
number: @options[:number] || false,
|
|
202
|
+
end_of_line: @options[:end_of_line] || :lf
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Mdlint
|
|
6
|
+
class Config
|
|
7
|
+
CONFIG_FILES = %w[
|
|
8
|
+
.mdlint.yml
|
|
9
|
+
.mdlint.yaml
|
|
10
|
+
mdlint.yml
|
|
11
|
+
mdlint.yaml
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
DEFAULT_OPTIONS = {
|
|
15
|
+
check: false,
|
|
16
|
+
diff: false,
|
|
17
|
+
quiet: false,
|
|
18
|
+
exclude: []
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :options
|
|
22
|
+
|
|
23
|
+
def initialize(base_path = Dir.pwd)
|
|
24
|
+
@base_path = base_path
|
|
25
|
+
@options = DEFAULT_OPTIONS.dup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def load
|
|
29
|
+
config_file = find_config_file
|
|
30
|
+
return @options unless config_file
|
|
31
|
+
|
|
32
|
+
file_options = load_config_file(config_file)
|
|
33
|
+
merge_options(file_options)
|
|
34
|
+
@options
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def merge(cli_options)
|
|
38
|
+
cli_options.each do |key, value|
|
|
39
|
+
case key
|
|
40
|
+
when :exclude
|
|
41
|
+
@options[:exclude] = (@options[:exclude] + value).uniq
|
|
42
|
+
else
|
|
43
|
+
@options[key] = value unless value.nil?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
@options
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def find_config_file
|
|
52
|
+
current = @base_path
|
|
53
|
+
|
|
54
|
+
loop do
|
|
55
|
+
CONFIG_FILES.each do |name|
|
|
56
|
+
path = File.join(current, name)
|
|
57
|
+
return path if File.exist?(path)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
parent = File.dirname(current)
|
|
61
|
+
break if parent == current
|
|
62
|
+
|
|
63
|
+
current = parent
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def load_config_file(path)
|
|
70
|
+
content = File.read(path)
|
|
71
|
+
parsed = YAML.safe_load(content, symbolize_names: true) || {}
|
|
72
|
+
normalize_options(parsed)
|
|
73
|
+
rescue Psych::SyntaxError => e
|
|
74
|
+
warn "Warning: Invalid YAML in #{path}: #{e.message}"
|
|
75
|
+
{}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def normalize_options(parsed)
|
|
79
|
+
options = {}
|
|
80
|
+
|
|
81
|
+
options[:check] = parsed[:check] if parsed.key?(:check)
|
|
82
|
+
options[:diff] = parsed[:diff] if parsed.key?(:diff)
|
|
83
|
+
options[:quiet] = parsed[:quiet] if parsed.key?(:quiet)
|
|
84
|
+
|
|
85
|
+
if parsed[:exclude]
|
|
86
|
+
options[:exclude] = Array(parsed[:exclude])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
options
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def merge_options(file_options)
|
|
93
|
+
file_options.each do |key, value|
|
|
94
|
+
case key
|
|
95
|
+
when :exclude
|
|
96
|
+
@options[:exclude] = (@options[:exclude] + value).uniq
|
|
97
|
+
else
|
|
98
|
+
@options[key] = value
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mdlint
|
|
4
|
+
module Linter
|
|
5
|
+
class Rule
|
|
6
|
+
class << self
|
|
7
|
+
attr_accessor :rule_id, :description
|
|
8
|
+
|
|
9
|
+
def inherited(subclass)
|
|
10
|
+
super
|
|
11
|
+
RuleRegistry.register(subclass)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :violations
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@violations = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def check(_tokens, _source)
|
|
22
|
+
raise NotImplementedError, "Subclasses must implement #check"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fix(_tokens, _source)
|
|
26
|
+
raise NotImplementedError, "Subclasses must implement #fix"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
protected
|
|
30
|
+
|
|
31
|
+
def add_violation(message:, line:, column: nil, fixable: false)
|
|
32
|
+
@violations << Violation.new(
|
|
33
|
+
rule_id: self.class.rule_id,
|
|
34
|
+
message: message,
|
|
35
|
+
line: line,
|
|
36
|
+
column: column,
|
|
37
|
+
fixable: fixable
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class RuleRegistry
|
|
43
|
+
@rules = []
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
attr_reader :rules
|
|
47
|
+
|
|
48
|
+
def register(rule_class)
|
|
49
|
+
@rules << rule_class
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def all
|
|
53
|
+
@rules
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def find(rule_id)
|
|
57
|
+
@rules.find { |r| r.rule_id == rule_id }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def clear
|
|
61
|
+
@rules = []
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mdlint
|
|
4
|
+
module Linter
|
|
5
|
+
class RuleEngine
|
|
6
|
+
attr_reader :violations
|
|
7
|
+
|
|
8
|
+
def initialize(options = {})
|
|
9
|
+
@options = options
|
|
10
|
+
@enabled_rules = options[:rules] || RuleRegistry.all.map(&:rule_id)
|
|
11
|
+
@disabled_rules = options[:disable] || []
|
|
12
|
+
@violations = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def check(tokens, source)
|
|
16
|
+
@violations = []
|
|
17
|
+
|
|
18
|
+
active_rules.each do |rule_class|
|
|
19
|
+
rule = rule_class.new
|
|
20
|
+
rule.check(tokens, source)
|
|
21
|
+
@violations.concat(rule.violations)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@violations.sort_by(&:line)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fix(tokens, source)
|
|
28
|
+
result = source
|
|
29
|
+
|
|
30
|
+
active_rules.each do |rule_class|
|
|
31
|
+
rule = rule_class.new
|
|
32
|
+
result = rule.fix(tokens, result)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def active_rules
|
|
41
|
+
RuleRegistry.all.select do |rule_class|
|
|
42
|
+
@enabled_rules.include?(rule_class.rule_id) &&
|
|
43
|
+
!@disabled_rules.include?(rule_class.rule_id)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mdlint
|
|
4
|
+
module Linter
|
|
5
|
+
module Rules
|
|
6
|
+
class FirstLineHeading < Rule
|
|
7
|
+
self.rule_id = "MD041"
|
|
8
|
+
self.description = "First line in file should be a top-level heading"
|
|
9
|
+
|
|
10
|
+
def check(tokens, _source)
|
|
11
|
+
first_content_token = tokens.find do |t|
|
|
12
|
+
%i[heading_open paragraph_open bullet_list_open ordered_list_open
|
|
13
|
+
blockquote_open fence code_block hr html_block].include?(t.type)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
return @violations unless first_content_token
|
|
17
|
+
|
|
18
|
+
if first_content_token.type != :heading_open
|
|
19
|
+
add_violation(
|
|
20
|
+
message: "First line should be a top-level heading",
|
|
21
|
+
line: (first_content_token.map&.first || 0) + 1,
|
|
22
|
+
fixable: false
|
|
23
|
+
)
|
|
24
|
+
elsif first_content_token.tag != "h1"
|
|
25
|
+
add_violation(
|
|
26
|
+
message: "First heading should be h1",
|
|
27
|
+
line: (first_content_token.map&.first || 0) + 1,
|
|
28
|
+
fixable: false
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@violations
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def fix(tokens, _source)
|
|
36
|
+
tokens
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|