Skip to main content

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, RuleMetadata, 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    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}