1use crate::iter::IterDelimited;
2use solar::parse::{
3 ast::{CommentKind, Span},
4 interface::{BytePos, CharPos, SourceMap, source_map::SourceFile},
5 lexer::token::RawTokenKind as TokenKind,
6};
7use std::fmt;
8
9mod comment;
10pub use comment::{Comment, CommentStyle};
11
12pub mod inline_config;
13
14pub const DISABLE_START: &str = "forgefmt: disable-start";
15pub const DISABLE_END: &str = "forgefmt: disable-end";
16
17pub struct Comments {
18 comments: std::collections::VecDeque<Comment>,
19}
20
21impl fmt::Debug for Comments {
22 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23 f.write_str("Comments")?;
24 f.debug_list().entries(self.iter()).finish()
25 }
26}
27
28impl Comments {
29 pub fn new(
30 sf: &SourceFile,
31 _sm: &SourceMap,
32 normalize_cmnts: bool,
33 group_cmnts: bool,
34 tab_width: Option<usize>,
35 ) -> Self {
36 let gatherer = CommentGatherer::new(sf, normalize_cmnts, tab_width).gather();
37
38 Self {
39 comments: if group_cmnts { gatherer.group().into() } else { gatherer.comments.into() },
40 }
41 }
42
43 pub fn peek(&self) -> Option<&Comment> {
44 self.comments.front()
45 }
46
47 #[allow(clippy::should_implement_trait)]
48 pub fn next(&mut self) -> Option<Comment> {
49 self.comments.pop_front()
50 }
51
52 pub fn iter(&self) -> impl Iterator<Item = &Comment> {
53 self.comments.iter()
54 }
55
56 pub fn push_front(&mut self, cmnt: Comment) {
64 self.comments.push_front(cmnt)
65 }
66
67 pub fn peek_trailing(
72 &self,
73 sm: &SourceMap,
74 span_pos: BytePos,
75 next_pos: Option<BytePos>,
76 ) -> Option<(&Comment, usize)> {
77 self.peek_trailing_with(span_pos, next_pos, |pos| {
78 sm.lookup_line(pos).ok().map(|line| line.line)
79 })
80 }
81
82 pub fn peek_trailing_in_file(
86 &self,
87 file: &SourceFile,
88 span_pos: BytePos,
89 next_pos: Option<BytePos>,
90 ) -> Option<(&Comment, usize)> {
91 self.peek_trailing_with(span_pos, next_pos, |pos| {
92 file.lookup_line(file.relative_position(pos))
93 })
94 }
95
96 fn peek_trailing_with(
97 &self,
98 span_pos: BytePos,
99 next_pos: Option<BytePos>,
100 line_at: impl Fn(BytePos) -> Option<usize>,
101 ) -> Option<(&Comment, usize)> {
102 self.comments.front()?;
103 let span_line = line_at(span_pos);
104 for (i, cmnt) in self.iter().enumerate() {
105 let comment_line = line_at(cmnt.pos());
107 if comment_line != span_line {
108 break;
109 }
110
111 if cmnt.pos() < span_pos {
113 continue;
114 }
115
116 if cmnt.pos() >= next_pos.unwrap_or_else(|| cmnt.pos() + BytePos(1)) {
118 break;
119 }
120
121 match cmnt.style {
123 CommentStyle::Mixed => {}
124 CommentStyle::Trailing => return Some((cmnt, i)),
125 _ => break,
126 }
127 }
128 None
129 }
130}
131
132struct CommentGatherer<'ast> {
133 sf: &'ast SourceFile,
134 text: &'ast str,
135 start_bpos: BytePos,
136 pos: usize,
137 comments: Vec<Comment>,
138 code_to_the_left: bool,
139 disabled_block_depth: usize,
140 tab_width: Option<usize>,
141}
142
143impl<'ast> CommentGatherer<'ast> {
144 fn new(sf: &'ast SourceFile, normalize_cmnts: bool, tab_width: Option<usize>) -> Self {
145 Self {
146 sf,
147 text: sf.src.as_str(),
148 start_bpos: sf.start_pos,
149 pos: 0,
150 comments: Vec::new(),
151 code_to_the_left: false,
152 disabled_block_depth: if normalize_cmnts { 0 } else { 1 },
153 tab_width,
154 }
155 }
156
157 fn gather(mut self) -> Self {
159 for token in solar::parse::Cursor::new(&self.text[self.pos..]) {
160 self.process_token(token);
161 }
162 self
163 }
164
165 fn group(self) -> Vec<Comment> {
170 let mut processed = Vec::new();
171 let mut cursor = self.comments.into_iter().peekable();
172
173 while let Some(mut current) = cursor.next() {
174 if current.kind == CommentKind::Line
175 && (current.style.is_trailing() || current.style.is_isolated())
176 {
177 let mut ref_line =
178 self.sf.lookup_line(self.sf.relative_position(current.span.hi())).unwrap();
179 while let Some(next_comment) = cursor.peek() {
180 if !next_comment.style.is_isolated()
181 || next_comment.kind != CommentKind::Line
182 || ref_line + 1
183 != self
184 .sf
185 .lookup_line(self.sf.relative_position(next_comment.span.lo()))
186 .unwrap()
187 {
188 break;
189 }
190
191 let next_to_merge = cursor.next().unwrap();
192 current.lines.extend(next_to_merge.lines);
193 current.span = current.span.to(next_to_merge.span);
194 ref_line += 1;
195 }
196 }
197
198 processed.push(current);
199 }
200
201 processed
202 }
203
204 fn make_span(&self, range: std::ops::Range<usize>) -> Span {
206 Span::new(self.start_bpos + range.start as u32, self.start_bpos + range.end as u32)
207 }
208
209 fn process_token(&mut self, token: solar::parse::lexer::token::RawToken) {
211 let token_range = self.pos..self.pos + token.len as usize;
212 let span = self.make_span(token_range.clone());
213 let token_text = &self.text[token_range];
214
215 if token_text.trim_start().contains(DISABLE_START) {
217 self.disabled_block_depth += 1;
218 } else if token_text.trim_start().contains(DISABLE_END) {
219 self.disabled_block_depth -= 1;
220 }
221
222 #[allow(clippy::collapsible_match)]
223 match token.kind {
224 TokenKind::Whitespace => {
225 if let Some(mut idx) = token_text.find('\n') {
226 self.code_to_the_left = false;
227
228 while let Some(next_newline) = token_text[idx + 1..].find('\n') {
229 idx += 1 + next_newline;
230 let pos = self.pos + idx;
231 self.comments.push(Comment {
232 is_doc: false,
233 kind: CommentKind::Line,
234 style: CommentStyle::BlankLine,
235 lines: vec![],
236 span: self.make_span(pos..pos),
237 });
238 if self.disabled_block_depth == 0 {
240 break;
241 }
242 }
243 }
244 }
245 TokenKind::BlockComment { is_doc, .. } => {
246 let code_to_the_right = !matches!(
247 self.text[self.pos + token.len as usize..].chars().next(),
248 Some('\r' | '\n')
249 );
250 let style = match (self.code_to_the_left, code_to_the_right) {
251 (_, true) => CommentStyle::Mixed,
252 (false, false) => CommentStyle::Isolated,
253 (true, false) => CommentStyle::Trailing,
254 };
255 let kind = CommentKind::Block;
256
257 let pos_in_file = self.start_bpos + BytePos(self.pos as u32);
259 let line_begin_in_file = line_begin_pos(self.sf, pos_in_file);
260 let line_begin_pos = (line_begin_in_file - self.start_bpos).to_usize();
261 let tab_width = if is_doc { 1 } else { self.tab_width.unwrap_or(1) };
262 let mut col =
263 CharPos(estimate_line_width(&self.text[line_begin_pos..self.pos], tab_width));
264
265 if !is_doc && token_text.contains('\n') {
268 col = token_text.lines().skip(1).fold(col, |min, line| {
269 if line.is_empty() {
270 return min;
271 }
272 std::cmp::min(
273 CharPos(estimate_line_width(
274 &line[..line.len() - line.trim_start().len()],
275 tab_width,
276 )),
277 min,
278 )
279 })
280 };
281
282 let lines = self.split_block_comment_into_lines(token_text, is_doc, col);
283 self.comments.push(Comment { is_doc, kind, style, lines, span })
284 }
285 TokenKind::LineComment { is_doc } => {
286 let line =
287 if self.disabled_block_depth != 0 { token_text } else { token_text.trim_end() };
288 self.comments.push(Comment {
289 is_doc,
290 kind: CommentKind::Line,
291 style: if self.code_to_the_left {
292 CommentStyle::Trailing
293 } else {
294 CommentStyle::Isolated
295 },
296 lines: vec![line.into()],
297 span,
298 });
299 }
300 _ => {
301 self.code_to_the_left = true;
302 }
303 }
304 self.pos += token.len as usize;
305 }
306
307 fn split_block_comment_into_lines(
309 &self,
310 text: &str,
311 is_doc: bool,
312 col: CharPos,
313 ) -> Vec<String> {
314 if self.disabled_block_depth != 0 {
316 return vec![text.into()];
317 }
318
319 let mut res: Vec<String> = vec![];
320 let mut lines = text.lines();
321 if let Some(line) = lines.next() {
322 let line = line.trim_end();
323 if is_doc && let Some((_, second)) = line.split_once("/**") {
325 res.push("/**".to_string());
326 if !second.trim().is_empty() {
327 let line = normalize_block_comment_ws(second, col).trim_end();
328 if let Some((first, _)) = line.split_once("*/") {
330 if !first.trim().is_empty() {
331 res.push(format_doc_block_comment(first.trim_end(), self.tab_width));
332 }
333 res.push(" */".to_string());
334 } else {
335 res.push(format_doc_block_comment(line.trim_end(), self.tab_width));
336 }
337 }
338 } else {
339 res.push(line.to_string());
340 }
341 }
342
343 for (pos, line) in lines.delimited() {
344 let indent_end = line.len() - line.trim_start().len();
345 let mut expanded = String::new();
346 let line = if !is_doc
347 && let Some(tab_width) = self.tab_width
348 && line[..indent_end].contains('\t')
349 {
350 expanded.extend(std::iter::repeat_n(
352 ' ',
353 estimate_line_width(&line[..indent_end], tab_width),
354 ));
355 expanded.push_str(&line[indent_end..]);
356 expanded.as_str()
357 } else {
358 line
359 };
360 let line = normalize_block_comment_ws(line, col).trim_end().to_string();
361 if !is_doc {
362 res.push(line);
363 continue;
364 }
365 if pos.is_last {
366 if let Some((first, _)) = line.split_once("*/")
368 && !first.trim().is_empty()
369 {
370 res.push(format_doc_block_comment(first.trim_end(), self.tab_width));
371 }
372 res.push(" */".to_string());
373 } else {
374 res.push(format_doc_block_comment(&line, self.tab_width));
375 }
376 }
377 res
378 }
379}
380
381fn all_whitespace(s: &str, col: CharPos) -> Option<usize> {
385 let mut idx = 0;
386 for (i, ch) in s.char_indices().take(col.to_usize()) {
387 if !ch.is_whitespace() {
388 return None;
389 }
390 idx = i + ch.len_utf8();
391 }
392 Some(idx)
393}
394
395fn first_non_whitespace(s: &str) -> Option<usize> {
398 let mut len = 0;
399 for (i, ch) in s.char_indices() {
400 if ch.is_whitespace() {
401 len = ch.len_utf8()
402 } else {
403 return if i == 0 { Some(0) } else { Some(i + 1 - len) };
404 }
405 }
406 None
407}
408
409fn normalize_block_comment_ws(s: &str, col: CharPos) -> &str {
412 let len = s.len();
413 if let Some(col) = all_whitespace(s, col) {
414 return if col < len { &s[col..] } else { "" };
415 }
416 if let Some(col) = first_non_whitespace(s) {
417 return &s[col..];
418 }
419 s
420}
421
422fn format_doc_block_comment(line: &str, tab_width: Option<usize>) -> String {
424 if line.is_empty() {
425 return (" *").to_string();
426 }
427
428 if let Some((_, rest_of_line)) = line.split_once('*') {
429 if rest_of_line.is_empty() {
430 (" *").to_string()
431 } else if let Some(tab_width) = tab_width {
432 let mut normalized = String::from(" *");
433 line_with_tabs(
434 &mut normalized,
435 rest_of_line,
436 tab_width,
437 Some(Consolidation::MinOneTab),
438 );
439 normalized
440 } else {
441 format!(" *{rest_of_line}",)
442 }
443 } else if let Some(tab_width) = tab_width {
444 let mut normalized = String::from(" *\t");
445 line_with_tabs(&mut normalized, line, tab_width, Some(Consolidation::WithoutSpaces));
446 normalized
447 } else {
448 format!(" * {line}")
449 }
450}
451
452pub enum Consolidation {
453 MinOneTab,
454 WithoutSpaces,
455}
456
457pub fn line_with_tabs(
462 output: &mut String,
463 line: &str,
464 tab_width: usize,
465 strategy: Option<Consolidation>,
466) {
467 let first_non_ws = line.find(|c| c != ' ' && c != '\t').unwrap_or(line.len());
469 let (leading_ws, rest_of_line) = line.split_at(first_non_ws);
470
471 let total_width =
473 leading_ws.chars().fold(0, |width, c| width + if c == ' ' { 1 } else { tab_width });
474 let (mut num_tabs, mut num_spaces) = (total_width / tab_width, total_width % tab_width);
475
476 match strategy {
478 Some(Consolidation::MinOneTab) => {
479 if num_tabs == 0 && num_spaces != 0 {
480 (num_tabs, num_spaces) = (1, 0);
481 } else if num_spaces != 0 {
482 (num_tabs, num_spaces) = (num_tabs + 1, 0);
483 }
484 }
485 Some(Consolidation::WithoutSpaces) if num_spaces != 0 => {
486 (num_tabs, num_spaces) = (num_tabs + 1, 0);
487 }
488 _ => (),
489 };
490
491 output.extend(std::iter::repeat_n('\t', num_tabs));
493 output.extend(std::iter::repeat_n(' ', num_spaces));
494 output.push_str(rest_of_line);
495}
496
497pub fn estimate_line_width(line: &str, tab_width: usize) -> usize {
499 line.chars().fold(0, |width, c| width + if c == '\t' { tab_width } else { 1 })
500}
501
502fn line_begin_pos(sf: &SourceFile, pos: BytePos) -> BytePos {
504 let pos = sf.relative_position(pos);
505 let line_index = sf.lookup_line(pos).unwrap();
506 let line_start_pos = sf.lines()[line_index];
507 sf.absolute_position(line_start_pos)
508}