mdlint 0.1.0 → 0.2.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.pre-commit-hooks.yaml +6 -0
  3. data/CHANGELOG.md +33 -0
  4. data/README.md +136 -4
  5. data/Rakefile +14 -0
  6. data/Steepfile +17 -0
  7. data/action.yml +54 -0
  8. data/benchmark/compare.rb +49 -0
  9. data/benchmark/format.rb +29 -0
  10. data/lib/mdlint/cache_store.rb +85 -0
  11. data/lib/mdlint/cli/output_formatter.rb +150 -0
  12. data/lib/mdlint/cli.rb +268 -106
  13. data/lib/mdlint/config.rb +87 -1
  14. data/lib/mdlint/dialect.rb +53 -0
  15. data/lib/mdlint/linter/directive_filter.rb +91 -0
  16. data/lib/mdlint/linter/rule.rb +25 -5
  17. data/lib/mdlint/linter/rule_engine.rb +44 -7
  18. data/lib/mdlint/linter/rules/code_block_syntax.rb +128 -0
  19. data/lib/mdlint/linter/rules/first_line_heading.rb +10 -3
  20. data/lib/mdlint/linter/rules/heading_increment.rb +4 -3
  21. data/lib/mdlint/linter/rules/heading_style.rb +24 -2
  22. data/lib/mdlint/linter/rules/japanese.rb +201 -0
  23. data/lib/mdlint/linter/rules/line_length.rb +37 -0
  24. data/lib/mdlint/linter/rules/link_check.rb +151 -0
  25. data/lib/mdlint/linter/rules/no_multiple_blanks.rb +1 -0
  26. data/lib/mdlint/linter/rules/no_trailing_spaces.rb +1 -0
  27. data/lib/mdlint/linter/rules/source_style.rb +317 -0
  28. data/lib/mdlint/linter/violation.rb +16 -1
  29. data/lib/mdlint/linter.rb +9 -3
  30. data/lib/mdlint/lsp.rb +176 -0
  31. data/lib/mdlint/parallel_runner.rb +42 -0
  32. data/lib/mdlint/parser/block_parser.rb +627 -50
  33. data/lib/mdlint/parser/inline_parser.rb +259 -27
  34. data/lib/mdlint/parser/state.rb +21 -2
  35. data/lib/mdlint/parser.rb +5 -5
  36. data/lib/mdlint/plugin.rb +31 -0
  37. data/lib/mdlint/renderer/html_renderer.rb +346 -0
  38. data/lib/mdlint/renderer/md_renderer.rb +147 -11
  39. data/lib/mdlint/renderer.rb +5 -0
  40. data/lib/mdlint/text_width.rb +39 -0
  41. data/lib/mdlint/toc.rb +80 -0
  42. data/lib/mdlint/token.rb +3 -1
  43. data/lib/mdlint/version.rb +1 -1
  44. data/lib/mdlint.rb +25 -5
  45. data/script/commonmark_compatibility.rb +24 -0
  46. data/script/fetch_commonmark_spec.rb +14 -0
  47. data/sig/internal.rbs +405 -0
  48. data/sig/mdlint.rbs +107 -0
  49. metadata +26 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f620c8c00a85c39a41cc1f81a4708ead9496071dd6cf984cd64eb7749bf646f
4
- data.tar.gz: 76c8f42560a6b98b8007b05911825017d34cc09dad59ee0545f2a87b863bfd01
3
+ metadata.gz: 25d6971b46664870bcf318a6013fe69079a6e8208f061d22e39ec18cce41d15a
4
+ data.tar.gz: 471f942064bb9944fd5344766811f32b9ab28e15112c873399e5fb034fa52770
5
5
  SHA512:
6
- metadata.gz: 202ea22fc2617cce04c03a0785ee64dfd64e62e306de58a6cf50257668b0992b2de1fa953cbe8d7374db4de0aeb99940c8dd98e936b34b47022316058a34f8cd
7
- data.tar.gz: b987544c415e6bfa021d7472080284c07a9f179b1ad46dddcc53944faa4e14a3f3a27db7202f72d8208deac05a95234a8f9b1eab5b3d40907cbd98aa0210eee0
6
+ metadata.gz: 241b2426e28d513cc63daccc62dc22ae264d0269d12301ae324ba8318ebe0a08c90f6a9523e421ee3e577dcedf694ae957c68034ec8bcd266db78502ca7e281d
7
+ data.tar.gz: a5e7d1800c8ec2c090b5de2d3605dafc66bf0ed087565dd97db4136b680b4a0464e70c603ed23960a54e395c92e7e1a14408898cdc90446887d204a65bc64ad6
@@ -0,0 +1,6 @@
1
+ - id: mdlint
2
+ name: mdlint
3
+ description: Check Markdown formatting and lint violations
4
+ entry: mdlint --check
5
+ language: system
6
+ files: \\.md$
data/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.2.0 - 2026-08-12
11
+
12
+ ### Added
13
+
14
+ - Added `lint`, `fix`, and `lsp` CLI commands while keeping formatting as the default command.
15
+ - Added lint fixes with `--fix`, `--fix-only`, and `--dry-run`.
16
+ - Added inline directives for disabling and enabling rules, including file-level and next-line controls.
17
+ - Added rule discovery and guidance through `--list-rules` and `--explain`, with Markdownlint-compatible rule IDs and aliases.
18
+ - Added configurable severities, fail levels, and text, JSON, SARIF, GitHub, Checkstyle, JUnit, and reviewdog output formats.
19
+ - Added YAML, TOML, and JSON front matter preservation during parsing and formatting.
20
+ - Added GFM tables, task lists, strikethrough, bare URL autolinks, and selectable Markdown dialects.
21
+ - Added HTML rendering through `Mdlint.html`.
22
+ - Added `--auto-gen-config`, parallel file processing, and content/configuration-hash caching.
23
+ - Added GitHub Action, reviewdog integration, and a pre-commit hook.
24
+ - Added LSP diagnostics, formatting, and quick fixes.
25
+ - Added the Japanese technical-writing preset (`JA001`–`JA007`), CJK-aware wrapping, link and anchor checks, and table-of-contents updates.
26
+ - Added optional fenced-code validation and formatting commands with configurable timeouts.
27
+ - Added 29 built-in lint rules and a stable plugin API for custom rules and dialect feature sets.
28
+
29
+ ### Changed
30
+
31
+ - `Mdlint.lint` now honors rule selections, disabled rules, aliases, severity settings, and configuration-file options from `.mdlint.yml`.
32
+ - `Mdlint.fix` and rule fixes now consistently return formatted source strings.
33
+ - Markdown parsing and HTML rendering now handle nested lists, loose list items, reference links and images, entities, code spans, block quotes, directives, and common URI autolinks more consistently.
34
+ - GFM behavior is enabled explicitly with `dialect: :gfm` or `--dialect gfm`; CommonMark remains the default dialect.
35
+
36
+ ### Fixed
37
+
38
+ - Fixed front matter being interpreted as thematic breaks, headings, or ordinary Markdown content.
39
+ - Fixed configured rules and disabled rules being ignored by the linter.
40
+ - Fixed inline links, reference links, images, escaped destinations, and nested link destinations being parsed or rendered incorrectly.
41
+ - Fixed formatter output changing document meaning in supported Markdown cases and added idempotence and HTML meaning-preservation checks.
42
+
10
43
  ## 0.1.0 - 2025-01-23
11
44
 
12
45
  - Initial release
data/README.md CHANGED
@@ -24,7 +24,18 @@
24
24
  - Linting with configurable rules and clear violations
25
25
  - Auto-formatting that follows mdformat conventions
26
26
  - CLI for CI-friendly workflows and local formatting
27
- - Simple YAML configuration file
27
+ - YAML/TOML/JSON front matter preservation
28
+ - GFM tables, task lists, strikethrough, and configurable dialects
29
+ - Text, JSON, SARIF, Checkstyle, JUnit, and GitHub diagnostic output
30
+ - Optional HTML rendering through the Ruby API
31
+ - GitHub Action and RBS/Steep signatures for integration and tooling
32
+ - reviewdog-compatible RDJSON output through the CLI and GitHub Action
33
+ - Language Server Protocol diagnostics, formatting, and quick fixes
34
+ - Parallel linting with a content/configuration-hash cache
35
+ - Japanese technical-writing preset, code-block syntax checks, link/anchor checks, and TOC updates
36
+ - Optional language commands for code-block validation and formatting
37
+ - 29 built-in lint rules with Markdownlint-compatible IDs and aliases
38
+ - Stable plugin registration for custom rules and dialect feature sets
28
39
 
29
40
  ## Quickstart
30
41
 
@@ -39,6 +50,12 @@ mdlint --check README.md
39
50
 
40
51
  # show diffs
41
52
  mdlint --diff README.md
53
+
54
+ # lint and report violations
55
+ mdlint lint docs/
56
+
57
+ # fix fixable lint violations
58
+ mdlint fix --dry-run docs/
42
59
  ```
43
60
 
44
61
  ## Installation
@@ -63,12 +80,16 @@ Command line:
63
80
  mdlint README.md docs/
64
81
  mdlint --check README.md
65
82
  mdlint --diff README.md
83
+ mdlint lint README.md
84
+ mdlint fix README.md
66
85
  ```
67
86
 
68
87
  Options:
69
88
 
70
89
  ```
71
- Usage: mdlint [options] [paths...]
90
+ Usage: mdlint [command] [options] [paths...]
91
+
92
+ Commands: format (default), lint, fix, lsp
72
93
 
73
94
  Options:
74
95
  -c, --check Check if files are formatted, exit with error if not
@@ -77,7 +98,34 @@ Options:
77
98
  -e, --exclude PATTERN Exclude files matching pattern
78
99
  -w, --wrap MODE Paragraph wrapping: keep (default), no, or INTEGER
79
100
  --number Use consecutive numbering for ordered lists
80
- --end-of-line MODE End of line: lf (default), crlf, keep
101
+ --end-of-line MODE End of line: lf (default), crlf, keep
102
+ --fix Fix fixable lint violations
103
+ --fix-only Apply fixes without reporting remaining violations
104
+ --dry-run Do not write fixes to files
105
+ --disable RULES Disable comma-separated rules
106
+ --rule RULES Run only comma-separated rules
107
+ --severity LEVEL Default severity: error, warning, or info
108
+ --fail-level LEVEL Fail at severity: error, warning, or info
109
+ --format FORMAT Lint output: text, json, sarif, github, checkstyle, junit, reviewdog
110
+ --stdin-filename NAME Filename to use for stdin diagnostics
111
+ --dialect DIALECT Markdown dialect: commonmark or gfm
112
+ --preset NAME Enable a rule preset, such as japanese
113
+ --check-links Check relative link, image, and anchor targets
114
+ --check-external-links Check external HTTP(S) links
115
+ --check-code-blocks Validate supported fenced code blocks
116
+ --code-block-command SPEC Validate a language block with LANG=COMMAND
117
+ --code-block-formatter SPEC Format a language block with LANG=COMMAND
118
+ --code-block-timeout SECONDS Timeout for external code-block commands
119
+ --toc Update table-of-contents markers
120
+ --no-table-align Do not pad GFM table columns
121
+ --jobs N Process files concurrently
122
+ --cache Cache lint diagnostics
123
+ --cache-path PATH Path for the lint cache
124
+ --lsp Run the Language Server Protocol server
125
+ --list-rules List available lint rules
126
+ --explain RULE Explain a lint rule
127
+ --require PATH Load a custom rule file
128
+ --auto-gen-config Write a config disabling current violations
81
129
  -v, --version Show version
82
130
  -h, --help Show help
83
131
  ```
@@ -96,6 +144,11 @@ violations = Mdlint.lint("# Heading\n\n\n\nParagraph")
96
144
  violations.each { |v| puts v }
97
145
 
98
146
  violations = Mdlint.lint_file("README.md")
147
+
148
+ fixed = Mdlint.fix("Line \n")
149
+ html = Mdlint.html("# Hello\n\n**world**\n")
150
+ gfm_tokens = Mdlint.parse("- [x] Done\n", dialect: :gfm)
151
+ toc_updated = Mdlint.update_toc("# Hello\n\n<!-- toc -->\nold\n<!-- toc -->\n")
99
152
  ```
100
153
 
101
154
  ## Configuration
@@ -113,6 +166,39 @@ quiet: false
113
166
  exclude:
114
167
  - "vendor/**/*.md"
115
168
  - "node_modules/**/*.md"
169
+
170
+ # Markdown dialect
171
+ dialect: commonmark # or gfm
172
+
173
+ # Optional integrations
174
+ preset: japanese
175
+ toc: false
176
+ check_links: false
177
+ check_external_links: false
178
+ check_code_blocks: false
179
+ code_block_timeout: 10
180
+ jobs: 2
181
+ cache: true
182
+ cache_path: .mdlint_cache
183
+
184
+ # Rule configuration (markdownlint-compatible IDs and aliases are accepted)
185
+ rules:
186
+ MD013:
187
+ enabled: true
188
+ line_length: 120
189
+ ignore_code_blocks: true
190
+ MD009: false
191
+
192
+ # Severity and CI threshold
193
+ severity: warning
194
+ fail_level: error
195
+
196
+ # Custom rule files
197
+ plugins:
198
+ - "./rules/my_rule.rb"
199
+
200
+ # A plugin may register a feature subset for a project-specific dialect.
201
+ # Mdlint::Plugin.register_dialect(:docs, features: [:tables])
116
202
  ```
117
203
 
118
204
  ## Lint Rules
@@ -124,13 +210,44 @@ Key rules:
124
210
  - No trailing spaces
125
211
  - No multiple consecutive blank lines
126
212
  - First line should be a top-level heading
213
+ - Lines should respect MD013's configured length
214
+ - Relative links, images, and anchors can be checked with `mdlint lint --check-links`.
215
+ - External HTTP(S) links are opt-in with `--check-external-links`.
216
+ - `--preset japanese` enables JA001–JA007 writing checks; options include `sentence_length` and `max_commas`.
217
+ - `--check-code-blocks` validates JSON and Ruby fenced blocks.
218
+ - `--code-block-command python='python -m py_compile -'` adds opt-in validation for another language; commands receive block content on stdin.
219
+ - `--code-block-formatter python='black -'` can rewrite an external language block; commands are bounded by `code_block_timeout` (10 seconds by default).
220
+ - `--toc` regenerates content between paired `<!-- toc -->` or `<!-- toc:start -->` / `<!-- toc:end -->` markers.
221
+
222
+ Plugins can use the stable API from a required Ruby file:
223
+
224
+ ```ruby
225
+ class MyRule < Mdlint::Linter::Rule
226
+ self.rule_id = "PL001"
227
+ self.aliases = ["my-rule"]
228
+ self.description = "Project-specific Markdown rule"
229
+ end
230
+
231
+ Mdlint::Plugin.register_rule(MyRule)
232
+ Mdlint::Plugin.register_dialect(:docs, features: [:tables, :task_lists])
233
+ ```
234
+
235
+ Inline directives can suppress diagnostics for a section or one line:
236
+
237
+ ```markdown
238
+ <!-- mdlint-disable MD013 -->
239
+ long content is allowed here
240
+ <!-- mdlint-enable MD013 -->
241
+ <!-- mdlint-disable-next-line MD001 -->
242
+ ### Intentional jump
243
+ ```
127
244
 
128
245
  ## Formatting Style
129
246
 
130
247
  | Element | Style |
131
248
  |---------|-------|
132
249
  | Headings | ATX style only (`#`) |
133
- | Bullet lists | Hyphen (`-`), alternating for nested |
250
+ | Bullet lists | Hyphen (`-`) at every nesting level |
134
251
  | Ordered lists | All items use `1.` (minimizes diffs) |
135
252
  | Code blocks | Fenced style (`` ``` ``) |
136
253
  | Horizontal rules | 70 underscores |
@@ -153,6 +270,7 @@ Block elements:
153
270
  - Horizontal rules (`---`, `***`, `___`)
154
271
  - HTML blocks
155
272
  - Reference definitions
273
+ - Footnotes, fenced math blocks, GitHub alerts, and MDX/JSX component blocks
156
274
 
157
275
  Inline elements:
158
276
 
@@ -165,11 +283,25 @@ Inline elements:
165
283
  - Autolinks (`<https://example.com>`)
166
284
  - Hard breaks (backslash + newline)
167
285
 
286
+ With `dialect: gfm`, tables, task lists, strikethrough, and bare URL autolinks are supported. Footnote definitions/references, fenced math blocks, GitHub alerts, and MDX/JSX blocks are preserved and included in HTML output.
287
+
168
288
  ## Development
169
289
 
170
290
  ```bash
171
291
  bin/setup
172
292
  bundle exec rspec
293
+ rbs validate
294
+ steep check --no-daemon --severity-level error
295
+
296
+ # Optional quality tools
297
+ ruby script/fetch_commonmark_spec.rb
298
+ bundle exec rspec spec/commonmark_spec.rb
299
+ ITERATIONS=100 ruby benchmark/format.rb README.md
300
+ ruby script/commonmark_compatibility.rb spec/fixtures/commonmark_smoke.json
301
+ ITERATIONS=100 ruby benchmark/compare.rb README.md
302
+ # Property tests cover formatter idempotence and HTML meaning preservation.
303
+ bundle exec rspec spec/mdlint/property_spec.rb
304
+ bundle exec rake property
173
305
  ```
174
306
 
175
307
  ## Contributing
data/Rakefile CHANGED
@@ -4,5 +4,19 @@ require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
+ RSpec::Core::RakeTask.new(:property) do |task|
8
+ task.pattern = "spec/mdlint/property_spec.rb"
9
+ end
10
+
11
+ namespace :commonmark do
12
+ task :fetch do
13
+ sh "ruby script/fetch_commonmark_spec.rb"
14
+ end
15
+
16
+ task :compatibility do
17
+ sh "ruby script/fetch_commonmark_spec.rb spec/fixtures/commonmark.json"
18
+ sh "ruby script/commonmark_compatibility.rb spec/fixtures/commonmark.json"
19
+ end
20
+ end
7
21
 
8
22
  task default: :spec
data/Steepfile ADDED
@@ -0,0 +1,17 @@
1
+ target :lib do
2
+ signature "sig"
3
+ library "cgi"
4
+ library "digest"
5
+ library "fileutils"
6
+ library "json"
7
+ library "net-http"
8
+ library "open3"
9
+ library "optparse"
10
+ library "ripper"
11
+ library "shellwords"
12
+ library "stringio"
13
+ library "timeout"
14
+ library "uri"
15
+ library "yaml"
16
+ check "lib"
17
+ end
data/action.yml ADDED
@@ -0,0 +1,54 @@
1
+ name: mdlint
2
+ description: Lint and format Markdown files with mdlint
3
+ branding:
4
+ icon: check-circle
5
+ color: blue
6
+ inputs:
7
+ args:
8
+ description: Arguments passed to mdlint
9
+ required: false
10
+ default: lint --format github
11
+ ruby-version:
12
+ description: Ruby version used by the action
13
+ required: false
14
+ default: '3.2'
15
+ reviewdog:
16
+ description: Run diagnostics through reviewdog
17
+ required: false
18
+ default: 'false'
19
+ reviewdog-reporter:
20
+ description: reviewdog reporter name
21
+ required: false
22
+ default: github-pr-check
23
+ reviewdog-filter-mode:
24
+ description: reviewdog filter mode
25
+ required: false
26
+ default: added
27
+ runs:
28
+ using: composite
29
+ steps:
30
+ - name: Set up Ruby
31
+ uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
32
+ with:
33
+ ruby-version: ${{ inputs.ruby-version }}
34
+ - name: Install mdlint
35
+ shell: bash
36
+ run: gem install mdlint --no-document # zizmor: ignore[adhoc-packages]
37
+ - name: Set up reviewdog
38
+ if: inputs.reviewdog == 'true'
39
+ uses: reviewdog/action-setup@d8a7baabd7f3e8544ee4dbde3ee41d0011c3a93f # v1
40
+ with:
41
+ reviewdog_version: latest
42
+ - name: Run mdlint
43
+ shell: bash
44
+ env:
45
+ MDLINT_ARGS: ${{ inputs.args }}
46
+ REVIEWDOG_ENABLED: ${{ inputs.reviewdog }}
47
+ REVIEWDOG_REPORTER: ${{ inputs.reviewdog-reporter }}
48
+ REVIEWDOG_FILTER_MODE: ${{ inputs.reviewdog-filter-mode }}
49
+ run: |
50
+ if [ "$REVIEWDOG_ENABLED" = "true" ]; then
51
+ mdlint $MDLINT_ARGS --format reviewdog | reviewdog -f=rdjson "-reporter=$REVIEWDOG_REPORTER" "-filter-mode=$REVIEWDOG_FILTER_MODE"
52
+ else
53
+ mdlint $MDLINT_ARGS
54
+ fi
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/mdlint"
4
+
5
+ path = ARGV.first
6
+ source = path && File.file?(path) ? File.read(path) : <<~MARKDOWN
7
+ # Benchmark document
8
+
9
+ A paragraph with **strong text**, `inline code`, and a [link](https://example.com).
10
+
11
+ - one
12
+ - two
13
+
14
+ ```ruby
15
+ puts "hello"
16
+ ```
17
+ MARKDOWN
18
+ iterations = Integer(ENV.fetch("ITERATIONS", "100"))
19
+
20
+ adapters = { "mdlint" => -> { Mdlint.html(source) } }
21
+
22
+ begin
23
+ require "kramdown"
24
+ adapters["kramdown"] = -> { Kramdown::Document.new(source).to_html }
25
+ rescue LoadError
26
+ warn "kramdown: unavailable (install it to include this adapter)"
27
+ end
28
+
29
+ begin
30
+ require "commonmarker"
31
+ adapters["commonmarker"] = lambda do
32
+ if Commonmarker.respond_to?(:to_html)
33
+ Commonmarker.to_html(source)
34
+ elsif Commonmarker.respond_to?(:render_doc)
35
+ Commonmarker.render_doc(source).to_html
36
+ else
37
+ raise "unsupported commonmarker API"
38
+ end
39
+ end
40
+ rescue LoadError
41
+ warn "commonmarker: unavailable (install it to include this adapter)"
42
+ end
43
+
44
+ adapters.each do |name, formatter|
45
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
46
+ iterations.times { formatter.call }
47
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
48
+ puts format("%-14s %.6fs (%.2f ops/s)", name, elapsed, iterations / elapsed)
49
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/mdlint"
4
+
5
+ path = ARGV.first
6
+ source = path && File.file?(path) ? File.read(path) : <<~MARKDOWN
7
+ # Benchmark document
8
+
9
+ This is a representative paragraph with **strong text**, `inline code`, and a [link](https://example.com).
10
+
11
+ - one
12
+ - two
13
+
14
+ ```ruby
15
+ puts "hello"
16
+ ```
17
+ MARKDOWN
18
+
19
+ iterations = Integer(ENV.fetch("ITERATIONS", "100"))
20
+ measure = lambda do |label, &block|
21
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
22
+ iterations.times(&block)
23
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
24
+ puts format("%-16s %.6fs (%.2f ops/s)", label, elapsed, iterations / elapsed)
25
+ end
26
+
27
+ measure.call("format x#{iterations}") { Mdlint.format(source) }
28
+ measure.call("html x#{iterations}") { Mdlint.html(source) }
29
+ measure.call("lint x#{iterations}") { Mdlint.lint(source) }
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module Mdlint
8
+ class CacheStore
9
+ def initialize(path)
10
+ @path = path
11
+ @mutex = Mutex.new
12
+ @data = load_data
13
+ @dirty = false
14
+ end
15
+
16
+ def key(source, options)
17
+ Digest::SHA256.hexdigest(JSON.generate([source, canonicalize(options)]))
18
+ end
19
+
20
+ def fetch(key)
21
+ @mutex.synchronize do
22
+ values = @data[key]
23
+ values&.map do |value|
24
+ attributes = symbolize(value)
25
+ Linter::Violation.new(
26
+ rule_id: attributes[:rule_id].to_s,
27
+ message: attributes[:message].to_s,
28
+ line: attributes[:line].to_i,
29
+ column: attributes[:column],
30
+ severity: (attributes[:severity] || :warning).to_sym,
31
+ fixable: attributes[:fixable] == true
32
+ )
33
+ end
34
+ end
35
+ end
36
+
37
+ def store(key, violations)
38
+ @mutex.synchronize do
39
+ @data[key] = violations.map(&:to_h)
40
+ @dirty = true
41
+ end
42
+ end
43
+
44
+ def save
45
+ @mutex.synchronize do
46
+ return unless @dirty
47
+
48
+ parent = File.dirname(@path)
49
+ FileUtils.mkdir_p(parent) unless parent == "."
50
+ File.write(@path, JSON.pretty_generate(@data) + "\n")
51
+ @dirty = false
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def load_data
58
+ return {} unless File.file?(@path)
59
+
60
+ JSON.parse(File.read(@path))
61
+ rescue JSON::ParserError, SystemCallError
62
+ {}
63
+ end
64
+
65
+ def canonicalize(value)
66
+ case value
67
+ when Hash
68
+ value.keys.map(&:to_s).sort.to_h do |key|
69
+ original_key = value.keys.find { |candidate| candidate.to_s == key }
70
+ [key, canonicalize(value[original_key])]
71
+ end
72
+ when Array
73
+ value.map { |item| canonicalize(item) }
74
+ when Symbol
75
+ value.to_s
76
+ else
77
+ value
78
+ end
79
+ end
80
+
81
+ def symbolize(value)
82
+ value.each_with_object({}) { |(key, item), result| result[key.to_sym] = item }
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mdlint
6
+ class CLI
7
+ class OutputFormatter
8
+ LEVELS = { error: "error", warning: "warning", info: "note" }.freeze
9
+
10
+ def initialize(format)
11
+ @format = format.to_s.downcase
12
+ end
13
+
14
+ def render(entries)
15
+ return "" if entries.empty? && @format == "text"
16
+
17
+ case @format
18
+ when "json"
19
+ json(entries)
20
+ when "sarif"
21
+ sarif(entries)
22
+ when "github"
23
+ github(entries)
24
+ when "checkstyle"
25
+ checkstyle(entries)
26
+ when "junit"
27
+ junit(entries)
28
+ when "reviewdog", "rdjson"
29
+ reviewdog(entries)
30
+ else
31
+ text(entries)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def text(entries)
38
+ entries.map { |entry| "#{entry[:filename]}:#{entry[:violation]}" }.join("\n") + "\n"
39
+ end
40
+
41
+ def json(entries)
42
+ JSON.generate(entries.map do |entry|
43
+ entry[:violation].to_h.merge(file: entry[:filename])
44
+ end) + "\n"
45
+ end
46
+
47
+ def github(entries)
48
+ entries.map do |entry|
49
+ violation = entry[:violation]
50
+ command = violation.error? ? "error" : violation.info? ? "notice" : "warning"
51
+ location = "file=#{entry[:filename]},line=#{violation.line}"
52
+ location += ",col=#{violation.column}" if violation.column
53
+ "::#{command} #{location}::[#{violation.rule_id}] #{violation.message}"
54
+ end.join("\n") + "\n"
55
+ end
56
+
57
+ def sarif(entries)
58
+ rules = entries.map { |entry| entry[:violation].rule_id }.uniq.filter_map do |rule_id|
59
+ rule = Mdlint::Linter::RuleRegistry.find(rule_id)
60
+ next unless rule
61
+
62
+ {
63
+ id: rule.rule_id,
64
+ shortDescription: { text: rule.description.to_s }
65
+ }
66
+ end
67
+ rule_indexes = rules.each_with_index.to_h { |rule, index| [rule[:id], index] }
68
+
69
+ document = {
70
+ version: "2.1.0",
71
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
72
+ runs: [{
73
+ tool: { driver: { name: "mdlint", rules: rules } },
74
+ results: entries.map do |entry|
75
+ violation = entry[:violation]
76
+ location = {
77
+ physicalLocation: {
78
+ artifactLocation: { uri: entry[:filename] },
79
+ region: { startLine: violation.line }
80
+ }
81
+ }
82
+ location[:physicalLocation][:region][:startColumn] = violation.column if violation.column
83
+ {
84
+ ruleId: violation.rule_id,
85
+ ruleIndex: rule_indexes[violation.rule_id],
86
+ level: LEVELS.fetch(violation.severity, "warning"),
87
+ message: { text: violation.message },
88
+ locations: [location]
89
+ }
90
+ end
91
+ }]
92
+ }
93
+ JSON.generate(document) + "\n"
94
+ end
95
+
96
+ def checkstyle(entries)
97
+ files = entries.group_by { |entry| entry[:filename] }.map do |filename, file_entries|
98
+ errors = file_entries.map do |entry|
99
+ violation = entry[:violation]
100
+ attributes = {
101
+ line: violation.line,
102
+ column: violation.column,
103
+ severity: violation.severity,
104
+ message: "[#{violation.rule_id}] #{violation.message}",
105
+ source: violation.rule_id
106
+ }.compact.map { |key, value| "#{key}=\"#{xml_escape(value)}\"" }.join(" ")
107
+ " <error #{attributes}/>"
108
+ end
109
+ " <file name=\"#{xml_escape(filename)}\">\n#{errors.join("\n")}\n </file>"
110
+ end
111
+ "<checkstyle version=\"1.0\">\n#{files.join("\n")}\n</checkstyle>\n"
112
+ end
113
+
114
+ def junit(entries)
115
+ cases = entries.map do |entry|
116
+ violation = entry[:violation]
117
+ name = "#{entry[:filename]}:#{violation.line}:#{violation.rule_id}"
118
+ " <testcase name=\"#{xml_escape(name)}\"><failure message=\"#{xml_escape(violation.message)}\"/></testcase>"
119
+ end
120
+ "<testsuite name=\"mdlint\" tests=\"#{entries.length}\" failures=\"#{entries.length}\">\n#{cases.join("\n")}\n</testsuite>\n"
121
+ end
122
+
123
+ def reviewdog(entries)
124
+ document = {
125
+ source: { name: "mdlint" },
126
+ diagnostics: entries.map do |entry|
127
+ violation = entry[:violation]
128
+ column = violation.column || 1
129
+ {
130
+ message: "[#{violation.rule_id}] #{violation.message}",
131
+ location: {
132
+ path: entry[:filename],
133
+ range: {
134
+ start: { line: violation.line, column: column },
135
+ end: { line: violation.line, column: column + 1 }
136
+ }
137
+ },
138
+ severity: violation.severity.to_s.upcase
139
+ }
140
+ end
141
+ }
142
+ JSON.generate(document) + "\n"
143
+ end
144
+
145
+ def xml_escape(value)
146
+ value.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;").gsub("'", "&apos;")
147
+ end
148
+ end
149
+ end
150
+ end