1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
//! What:
//! The linter configuration the `markdown/autofix` policy hands `monochromatic-lint`:
//! one block that selects the policy's rules for Markdown and MDX files,
//! written once
//! per invocation to a private temporary file outside the repository.
//! Why:
//! The plan's contract (`doc/planning/unified-linter.md`,
//! section "Distribution and
//! consumers") is a one-rule configuration in a temporary file created with a proper
//! temporary-file call,
//! passed with `--config`,
//! so a configuration the repository
//! keeps for its own linting never changes what the commit-time policy does.
//! Each
//! rule runs at `warn`,
//! so a finding the fixes leave is a record on standard error
//! and exit status 0,
//! and only a run that could not finish exits non-zero.
//! The
//! document is built as a value and printed by the JSONC emitter,
//! so no pattern text
//! can break out of its string.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! // const file = await writeOneRuleConfiguration({ rules, exclude }); // '/tmp/cli-git-markdown-Ab12Cd.jsonc'
//! ```
/// Import the selectable rules and their configuration spelling.
use super::config_schema::{MarkdownRule, markdown_rule_name};
/// Import the JSONC value model and its emitter.
use monochromatic_jsonc_edit::{JsoncEntry, JsoncKey, JsoncValue, emit_jsonc_value};
/// `Write` adds `write_all` to the temporary file.
use std::io::Write;
/// `Path` is a borrowed filesystem path.
use std::path::Path;
/// What:
/// `NamedTempFile` is a uniquely named file created exclusively,
/// readable only by its
/// owner,
/// and removed when the value is dropped.
/// Why:
/// The linter opens the configuration by name.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// import { mkdtemp } from 'node:fs/promises';
/// ```
use tempfile::NamedTempFile;
/// The files the block selects,
/// relative to the linter's working directory,
/// the top level.
pub const MARKDOWN_FILE_PATTERNS: [&str; 2] = ["**/*.md", "**/*.mdx"];
/// What:
/// A JSON string value holding `text`.
/// `&str` borrows the text.
/// Why:
/// The emitter quotes and escapes it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const text = (value: string) => value;
/// ```
fn text(value: &str) -> JsoncValue {
// `.encode_utf16().collect()` turns the text into the UTF-16 units the model stores.
return JsoncValue::text_from_units(value.encode_utf16().collect());
}
/// What:
/// A record member named `key`.
/// Why:
/// Short name for the members of the block.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const member = (key: string, value: unknown) => [key, value];
/// ```
fn member(key: &str, value: JsoncValue) -> JsoncEntry {
return JsoncEntry {
key: JsoncKey::from_text(key),
value,
};
}
/// What:
/// The settings of one rule.
/// `&[String]` borrows the policy's exclude patterns.
/// Why:
/// The LFS rule takes the patterns as its own `exclude` option,
/// as the installed
/// wrapper passed them to the linter it ran (`--lfs-image-exclude`).
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const ruleSettings = (rule, exclude) => ({ severity: 'warn', exclude });
/// ```
fn rule_settings(rule: MarkdownRule, exclude: &[String]) -> JsoncValue {
// `match` must name every rule, so a new rule cannot be given the wrong options.
match rule {
MarkdownRule::LfsImageUrl => {
let mut patterns: Vec<JsoncValue> = Vec::new();
for pattern in exclude {
patterns.push(text(pattern.as_str()));
}
return JsoncValue::record(vec![
member("severity", text("warn")),
member("exclude", JsoncValue::array(patterns)),
]);
}
}
}
/// What:
/// The configuration document for `rules` and `exclude`,
/// as JSONC text.
/// Why:
/// One block,
/// the policy's rules only,
/// every other rule off by omission.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function oneRuleConfiguration(rules: MarkdownRule[], exclude: string[]): string;
/// ```
pub fn one_rule_configuration(rules: &[MarkdownRule], exclude: &[String]) -> String {
let mut files: Vec<JsoncValue> = Vec::new();
for pattern in MARKDOWN_FILE_PATTERNS {
files.push(text(pattern));
}
let mut settings: Vec<JsoncEntry> = Vec::new();
for rule in rules {
settings.push(member(
format!("markdown/{}", markdown_rule_name(*rule)).as_str(),
rule_settings(*rule, exclude),
));
}
let block: JsoncValue = JsoncValue::record(vec![
member("files", JsoncValue::array(files)),
member("rules", JsoncValue::record(settings)),
]);
return emit_jsonc_value(&JsoncValue::array(vec![block]));
}
/// What:
/// Write `document` to a new private temporary file in `directory`.
/// `Result<NamedTempFile, String>` is the open file,
/// removed when dropped,
/// or the reason.
/// Why:
/// The file is made with an exclusive,
/// owner-only temporary-file call,
/// so no other
/// user can read or replace it between writing and the linter reading it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function writeConfiguration(document: string, directory: string): Promise<TemporaryFile>;
/// ```
pub fn write_configuration(document: &str, directory: &Path) -> Result<NamedTempFile, String> {
let created: std::io::Result<NamedTempFile> = tempfile::Builder::new()
.prefix("cli-git-markdown-")
.suffix(".jsonc")
.tempfile_in(directory);
let mut file: NamedTempFile = match created {
Ok(made) => made,
Err(error) => {
return Err(format!(
"cli-git could not create the temporary linter configuration in {}: {error}",
directory.display()
));
}
};
if let Err(error) = file.write_all(document.as_bytes()) {
return Err(format!(
"cli-git could not write the temporary linter configuration: {error}"
));
}
return Ok(file);
}
/// Document and file controls stay out of the release executable.
#[cfg(test)]
#[path = "markdown_linter_config_tests.rs"]
mod tests;