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, PartialEq, Eq)]
180enum TextContent {
181    None,
182    Value(String),
183    FilePath(PathBuf),
184}
185
186impl Default for TextContent {
187    fn default() -> Self {
188        Self::None
189    }
190}
191
192#[derive(Debug, PartialEq, Eq)]
193enum AppendLocation {
194    Start,
195    End,
196}
197
198impl AppendLocation {
199    fn append_comment(&self, token: &mut Token, comment: String) {
200        match self {
201            AppendLocation::Start => {
202                token.push_leading_trivia(TriviaKind::Comment.with_content(comment));
203            }
204            AppendLocation::End => {
205                token.push_trailing_trivia(TriviaKind::Comment.with_content(comment));
206            }
207        }
208    }
209}
210
211impl Default for AppendLocation {
212    fn default() -> Self {
213        Self::Start
214    }
215}
216
217#[cfg(test)]
218mod test {
219    use super::*;
220    use crate::rules::Rule;
221
222    use insta::assert_json_snapshot;
223
224    #[test]
225    fn serialize_rule_with_text() {
226        let rule: Box<dyn Rule> = Box::new(AppendTextComment::new("content"));
227
228        assert_json_snapshot!("append_text_comment_with_text", rule);
229    }
230
231    #[test]
232    fn serialize_rule_with_text_at_end() {
233        let rule: Box<dyn Rule> = Box::new(AppendTextComment::new("content").at_end());
234
235        assert_json_snapshot!("append_text_comment_with_text_at_end", rule);
236    }
237
238    #[test]
239    fn configure_with_extra_field_error() {
240        let result = json5::from_str::<Box<dyn Rule>>(
241            r#"{
242            rule: 'append_text_comment',
243            text: '',
244            prop: "something",
245        }"#,
246        );
247        pretty_assertions::assert_eq!(result.unwrap_err().to_string(), "unexpected field 'prop'");
248    }
249
250    #[test]
251    fn configure_with_invalid_location_error() {
252        let result = json5::from_str::<Box<dyn Rule>>(
253            r#"{
254            rule: 'append_text_comment',
255            text: 'hello',
256            location: 'oops',
257        }"#,
258        );
259        pretty_assertions::assert_eq!(result.unwrap_err().to_string(), "unexpected value for field 'location': invalid value `oops` (must be `start` or `end`)");
260    }
261}