1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5use crate::nodes::{Block, Token, TriviaKind};
6use crate::rules::{
7 verify_property_collisions, verify_required_any_properties, Context, Rule, RuleConfiguration,
8 RuleConfigurationError, RuleMetadata, RuleProcessResult, RuleProperties,
9};
10
11use super::{FlawlessRule, ShiftTokenLine};
12
13pub const APPEND_TEXT_COMMENT_RULE_NAME: &str = "append_text_comment";
14
15#[derive(Debug, Default)]
17pub struct AppendTextComment {
18 metadata: RuleMetadata,
19 text_value: OnceLock<Result<String, String>>,
20 text_content: TextContent,
21 location: AppendLocation,
22}
23
24impl AppendTextComment {
25 pub fn new(value: impl Into<String>) -> Self {
26 Self {
27 metadata: RuleMetadata::default(),
28 text_value: Default::default(),
29 text_content: TextContent::Value(value.into()),
30 location: Default::default(),
31 }
32 }
33
34 pub fn from_file_content(file_path: impl Into<PathBuf>) -> Self {
35 Self {
36 metadata: RuleMetadata::default(),
37 text_value: Default::default(),
38 text_content: TextContent::FilePath(file_path.into()),
39 location: Default::default(),
40 }
41 }
42
43 pub fn at_end(mut self) -> Self {
44 self.location = AppendLocation::End;
45 self
46 }
47
48 fn text(&self, project_path: &Path) -> Result<String, String> {
49 self.text_value
50 .get_or_init(|| {
51 match &self.text_content {
52 TextContent::None => Err("".to_owned()),
53 TextContent::Value(value) => Ok(value.clone()),
54 TextContent::FilePath(file_path) => {
55 fs::read_to_string(project_path.join(file_path)).map_err(|err| {
56 format!("unable to read file `{}`: {}", file_path.display(), err)
57 })
58 }
59 }
60 .map(|content| {
61 if content.is_empty() {
62 "".to_owned()
63 } else if content.contains('\n') {
64 let mut equal_count = 0;
65
66 let close_comment = loop {
67 let close_comment = format!("]{}]", "=".repeat(equal_count));
68 if !content.contains(&close_comment) {
69 break close_comment;
70 }
71 equal_count += 1;
72 };
73
74 format!(
75 "--[{}[\n{}\n{}",
76 "=".repeat(equal_count),
77 content,
78 close_comment
79 )
80 } else {
81 format!("--{}", content)
82 }
83 })
84 })
85 .clone()
86 }
87}
88
89impl Rule for AppendTextComment {
90 fn process(&self, block: &mut Block, context: &Context) -> RuleProcessResult {
91 let text = self.text(context.project_location())?;
92
93 if text.is_empty() {
94 return Ok(());
95 }
96
97 let shift_lines = text.lines().count();
98 ShiftTokenLine::new(shift_lines as isize).flawless_process(block, context);
99
100 match self.location {
101 AppendLocation::Start => {
102 self.location
103 .append_comment(block.mutate_first_token(), text);
104 }
105 AppendLocation::End => {
106 self.location
107 .append_comment(block.mutate_last_token(), text);
108 }
109 }
110
111 Ok(())
112 }
113}
114
115impl RuleConfiguration for AppendTextComment {
116 fn configure(&mut self, properties: RuleProperties) -> Result<(), RuleConfigurationError> {
117 verify_required_any_properties(&properties, &["text", "file"])?;
118 verify_property_collisions(&properties, &["text", "file"])?;
119
120 for (key, value) in properties {
121 match key.as_str() {
122 "text" => {
123 self.text_content = TextContent::Value(value.expect_string(&key)?);
124 }
125 "file" => {
126 self.text_content =
127 TextContent::FilePath(PathBuf::from(value.expect_string(&key)?));
128 }
129 "location" => {
130 self.location = match value.expect_string(&key)?.as_str() {
131 "start" => AppendLocation::Start,
132 "end" => AppendLocation::End,
133 unexpected => {
134 return Err(RuleConfigurationError::UnexpectedValue {
135 property: "location".to_owned(),
136 message: format!(
137 "invalid value `{}` (must be `start` or `end`)",
138 unexpected
139 ),
140 })
141 }
142 };
143 }
144 _ => return Err(RuleConfigurationError::UnexpectedProperty(key)),
145 }
146 }
147
148 Ok(())
149 }
150
151 fn get_name(&self) -> &'static str {
152 APPEND_TEXT_COMMENT_RULE_NAME
153 }
154
155 fn serialize_to_properties(&self) -> RuleProperties {
156 let mut properties = RuleProperties::new();
157
158 match self.location {
159 AppendLocation::Start => {}
160 AppendLocation::End => {
161 properties.insert("location".to_owned(), "end".into());
162 }
163 }
164
165 match &self.text_content {
166 TextContent::None => {}
167 TextContent::Value(value) => {
168 properties.insert("text".to_owned(), value.into());
169 }
170 TextContent::FilePath(file_path) => {
171 properties.insert(
172 "file".to_owned(),
173 file_path.to_string_lossy().to_string().into(),
174 );
175 }
176 }
177
178 properties
179 }
180
181 fn set_metadata(&mut self, metadata: RuleMetadata) {
182 self.metadata = metadata;
183 }
184
185 fn metadata(&self) -> &RuleMetadata {
186 &self.metadata
187 }
188}
189
190#[derive(Debug, Default, PartialEq, Eq)]
191enum TextContent {
192 #[default]
193 None,
194 Value(String),
195 FilePath(PathBuf),
196}
197
198#[derive(Debug, Default, PartialEq, Eq)]
199enum AppendLocation {
200 #[default]
201 Start,
202 End,
203}
204
205impl AppendLocation {
206 fn append_comment(&self, token: &mut Token, comment: String) {
207 match self {
208 AppendLocation::Start => {
209 token.insert_leading_trivia(0, TriviaKind::Comment.with_content(comment));
210 token.insert_leading_trivia(1, TriviaKind::Whitespace.with_content("\n"));
211 }
212 AppendLocation::End => {
213 token.push_trailing_trivia(TriviaKind::Comment.with_content(comment));
214 }
215 }
216 }
217}
218
219#[cfg(test)]
220mod test {
221 use super::*;
222 use crate::rules::Rule;
223
224 use insta::assert_json_snapshot;
225
226 #[test]
227 fn serialize_rule_with_text() {
228 let rule: Box<dyn Rule> = Box::new(AppendTextComment::new("content"));
229
230 assert_json_snapshot!(rule, @r###"
231 {
232 "rule": "append_text_comment",
233 "text": "content"
234 }
235 "###);
236 }
237
238 #[test]
239 fn serialize_rule_with_text_at_end() {
240 let rule: Box<dyn Rule> = Box::new(AppendTextComment::new("content").at_end());
241
242 assert_json_snapshot!(rule, @r###"
243 {
244 "rule": "append_text_comment",
245 "location": "end",
246 "text": "content"
247 }
248 "###);
249 }
250
251 #[test]
252 fn configure_with_extra_field_error() {
253 let result = json5::from_str::<Box<dyn Rule>>(
254 r#"{
255 rule: 'append_text_comment',
256 text: '',
257 prop: "something",
258 }"#,
259 );
260 insta::assert_snapshot!(result.unwrap_err().to_string(), @"unexpected field 'prop' at line 1 column 1");
261 }
262
263 #[test]
264 fn configure_with_invalid_location_error() {
265 let result = json5::from_str::<Box<dyn Rule>>(
266 r#"{
267 rule: 'append_text_comment',
268 text: 'hello',
269 location: 'oops',
270 }"#,
271 );
272 insta::assert_snapshot!(result.unwrap_err().to_string(), @"unexpected value for field 'location': invalid value `oops` (must be `start` or `end`) at line 1 column 1");
273 }
274}