darklua_core/rules/
append_text_comment.rs

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