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 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 (`![alt](src)`)
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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/exe/mdlint ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "mdlint"
5
+ require "mdlint/cli"
6
+
7
+ exit Mdlint::CLI.new(ARGV).run || 0
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