Skip to content

Commit b96d2e2

Browse files
authored
feat: add support for alias command formatting and preserve comment spacing (#170)
1 parent 4a9e384 commit b96d2e2

12 files changed

Lines changed: 228 additions & 16 deletions

src/formatting/calls.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ pub(super) const BLOCK_COMMANDS: &[&str] = &["for", "while", "loop", "module"];
1919
pub(super) const CONDITIONAL_COMMANDS: &[&str] = &["if", "try"];
2020
pub(super) const DEF_COMMANDS: &[&str] = &["def", "def-env", "export def"];
2121
pub(super) const EXTERN_COMMANDS: &[&str] = &["extern", "export extern"];
22+
pub(super) const ALIAS_COMMANDS: &[&str] = &["alias", "export alias"];
2223
pub(super) const LET_COMMANDS: &[&str] = &["let", "let-env", "mut", "const", "export const"];
2324

2425
impl<'a> Formatter<'a> {
@@ -47,6 +48,11 @@ impl<'a> Formatter<'a> {
4748
return;
4849
}
4950

51+
if matches!(cmd_type, CommandType::Alias) {
52+
self.format_alias_call(call);
53+
return;
54+
}
55+
5056
if decl_name == "for" {
5157
self.format_for_call(call);
5258
return;
@@ -270,12 +276,90 @@ impl<'a> Formatter<'a> {
270276
}
271277
}
272278

279+
/// Format alias definitions while preserving the literal right-hand side.
280+
///
281+
/// The parser resolves alias references semantically, which can expand the
282+
/// RHS if it is re-rendered from the AST. Preserve the original source text
283+
/// after `=` to keep alias definitions idempotent.
284+
pub(super) fn format_alias_call(&mut self, call: &nu_protocol::ast::Call) {
285+
let positional: Vec<&Expression> = call
286+
.arguments
287+
.iter()
288+
.filter_map(|arg| match arg {
289+
Argument::Positional(expr) | Argument::Unknown(expr) => Some(expr),
290+
_ => None,
291+
})
292+
.collect();
293+
294+
let Some(name) = positional.first() else {
295+
for arg in &call.arguments {
296+
self.format_call_argument(arg, &CommandType::Alias);
297+
}
298+
return;
299+
};
300+
301+
self.space();
302+
self.format_expression(name);
303+
304+
let rhs_end = call
305+
.arguments
306+
.iter()
307+
.filter_map(|arg| match arg {
308+
Argument::Positional(expr) | Argument::Unknown(expr) | Argument::Spread(expr) => {
309+
Some(expr.span.end)
310+
}
311+
Argument::Named(named) => named
312+
.2
313+
.as_ref()
314+
.map_or(Some(named.0.span.end), |value| Some(value.span.end)),
315+
})
316+
.max();
317+
318+
let Some(rhs_end) = rhs_end else {
319+
return;
320+
};
321+
322+
if name.span.end >= rhs_end || rhs_end > self.source.len() {
323+
self.format_regular_arguments(&call.arguments[1..]);
324+
return;
325+
}
326+
327+
let between = &self.source[name.span.end..rhs_end];
328+
let Some(eq_offset) = between.iter().position(|byte| *byte == b'=') else {
329+
self.format_regular_arguments(&call.arguments[1..]);
330+
return;
331+
};
332+
333+
let rhs_start = between[eq_offset + 1..]
334+
.iter()
335+
.position(|byte| !byte.is_ascii_whitespace())
336+
.map(|offset| name.span.end + eq_offset + 1 + offset);
337+
338+
let Some(rhs_start) = rhs_start else {
339+
self.write(" =");
340+
return;
341+
};
342+
343+
// Keep the exact user-authored alias RHS to avoid semantic expansion
344+
// when aliases reference other aliases.
345+
self.write(" = ");
346+
self.write_bytes(&self.source[rhs_start..rhs_end]);
347+
}
348+
349+
fn format_regular_arguments(&mut self, args: &[Argument]) {
350+
for arg in args {
351+
self.format_call_argument(arg, &CommandType::Regular);
352+
}
353+
}
354+
273355
/// Classify a command name into a [`CommandType`] for formatting purposes.
274356
pub(super) fn classify_command(name: &str) -> CommandType {
275357
if DEF_COMMANDS.contains(&name) {
276358
CommandType::Def
277359
} else if EXTERN_COMMANDS.contains(&name) {
278360
CommandType::Extern
361+
} else if ALIAS_COMMANDS.contains(&name) {
362+
CommandType::Alias
279363
} else if CONDITIONAL_COMMANDS.contains(&name) {
280364
CommandType::Conditional
281365
} else if LET_COMMANDS.contains(&name) {
@@ -336,6 +420,7 @@ impl<'a> Formatter<'a> {
336420
match cmd_type {
337421
CommandType::Def => self.format_def_argument(positional),
338422
CommandType::Extern => self.format_extern_argument(positional),
423+
CommandType::Alias => self.format_expression(positional),
339424
CommandType::Conditional => {
340425
self.conditional_context_depth += 1;
341426
self.format_block_or_expr(positional);

src/formatting/collections.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,17 +258,30 @@ impl<'a> Formatter<'a> {
258258
self.write("{");
259259
self.newline();
260260
self.indent_level += 1;
261+
262+
let closing_brace_pos = span.end.saturating_sub(1);
261263
for item in items {
264+
let item_start = match item {
265+
RecordItem::Pair(key, _) => key.span.start,
266+
RecordItem::Spread(_, expr) => expr.span.start,
267+
};
268+
self.write_comments_before(item_start);
262269
self.write_indent();
263270
self.format_record_item(item, preserve_compact);
271+
264272
// Capture inline comments on the same line as the record item.
265273
let item_end = match item {
266274
RecordItem::Pair(_, v) => v.span.end,
267275
RecordItem::Spread(_, expr) => expr.span.end,
268276
};
269-
self.write_inline_comment_bounded(item_end, None);
277+
self.write_inline_comment_bounded(item_end, Some(closing_brace_pos));
278+
// Advance beyond the current item so inter-item comments are
279+
// searched from the correct source window.
280+
self.last_pos = self.last_pos.max(item_end);
270281
self.newline();
271282
}
283+
284+
self.write_comments_before(closing_brace_pos);
272285
self.indent_level -= 1;
273286
self.write_indent();
274287
self.write("}");

src/formatting/comments.rs

Lines changed: 76 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,55 @@ pub(super) fn extract_comments(source: &[u8]) -> Vec<(Span, Vec<u8>)> {
6060
// ─────────────────────────────────────────────────────────────────────────────
6161

6262
impl<'a> Formatter<'a> {
63+
/// Return true when the source slice contains at least one blank line
64+
/// (two newline boundaries with only whitespace between them).
65+
fn source_has_blank_line(&self, start: usize, end: usize) -> bool {
66+
if start >= end || end > self.source.len() {
67+
return false;
68+
}
69+
70+
let mut previous_newline: Option<usize> = None;
71+
for (offset, byte) in self.source[start..end].iter().enumerate() {
72+
if *byte != b'\n' {
73+
continue;
74+
}
75+
76+
if let Some(prev) = previous_newline {
77+
let gap_start = start + prev + 1;
78+
let gap_end = start + offset;
79+
if self.source[gap_start..gap_end]
80+
.iter()
81+
.all(|b| b.is_ascii_whitespace())
82+
{
83+
return true;
84+
}
85+
}
86+
87+
previous_newline = Some(offset);
88+
}
89+
90+
false
91+
}
92+
93+
fn ensure_trailing_newlines(&mut self, min_newlines: usize) {
94+
if self.output.is_empty() || min_newlines == 0 {
95+
return;
96+
}
97+
98+
// Count contiguous trailing newlines so we can top up to the requested
99+
// separation without over-emitting line breaks.
100+
let existing = self
101+
.output
102+
.iter()
103+
.rev()
104+
.take_while(|&&byte| byte == b'\n')
105+
.count();
106+
107+
for _ in existing..min_newlines {
108+
self.newline();
109+
}
110+
}
111+
63112
/// Emit all comments that fall between `last_pos` and `pos`, each on its
64113
/// own line with the current indentation.
65114
pub(super) fn write_comments_before(&mut self, pos: usize) {
@@ -75,24 +124,29 @@ impl<'a> Formatter<'a> {
75124

76125
comments_to_write.sort_by_key(|(_, start, _)| *start);
77126

127+
let Some((_, first_start, _)) = comments_to_write.first() else {
128+
return;
129+
};
130+
131+
let leading_newlines = if self.source_has_blank_line(self.last_pos, *first_start) {
132+
2
133+
} else {
134+
1
135+
};
136+
// Preserve spacing before a standalone comment group.
137+
self.ensure_trailing_newlines(leading_newlines);
138+
78139
let mut prev_comment_end: Option<usize> = None;
79140
for (idx, start, content) in &comments_to_write {
80141
self.written_comments[*idx] = true;
81142

82-
// Preserve blank lines between comment groups by checking
83-
// whether the original source has a blank line between the
84-
// previous comment and this one.
85143
if let Some(prev_end) = prev_comment_end {
86-
let between = &self.source[prev_end..*start];
87-
let newline_count = between.iter().filter(|&&b| b == b'\n').count();
88-
if newline_count >= 2 {
89-
// There was at least one blank line in the source
90-
// between the two comments — emit one now.
91-
if !self.at_line_start {
92-
self.newline();
93-
}
94-
self.newline();
95-
}
144+
let between_newlines = if self.source_has_blank_line(prev_end, *start) {
145+
2
146+
} else {
147+
1
148+
};
149+
self.ensure_trailing_newlines(between_newlines);
96150
}
97151

98152
if !self.at_line_start {
@@ -108,6 +162,15 @@ impl<'a> Formatter<'a> {
108162

109163
prev_comment_end = Some(start + content.len());
110164
}
165+
166+
if let Some(last_comment_end) = prev_comment_end {
167+
self.last_pos = last_comment_end;
168+
if self.source_has_blank_line(last_comment_end, pos) {
169+
// Preserve a blank separator when comments are followed by a
170+
// spaced-apart statement group.
171+
self.ensure_trailing_newlines(2);
172+
}
173+
}
111174
}
112175

113176
/// Emit an inline comment (on the same line) that appears after `after_pos`,

src/formatting/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ pub(crate) struct Formatter<'a> {
8080
pub(crate) enum CommandType {
8181
Def,
8282
Extern,
83+
Alias,
8384
Conditional,
8485
Let,
8586
Block,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
alias fdr = fd --follow --hidden --no-ignore
2+
alias fdf = fdr --follow --hidden --no-ignore --fixed-strings
3+
alias fdg = fdr --follow --hidden --no-ignore --glob
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env nu
2+
3+
let x = 1
4+
5+
# This is a nice comment.
6+
#
7+
# It's not associated with any particular block. It just describes things about
8+
# the contents of this file, or the broader section of code.
9+
10+
let y = 2
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
let a = {
2+
my_key: "my_key"
3+
# a comment
4+
my_other_key: "my_other_key"
5+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
alias fdr = fd --follow --hidden --no-ignore
2+
alias fdf = fdr --follow --hidden --no-ignore --fixed-strings
3+
alias fdg = fdr --follow --hidden --no-ignore --glob
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env nu
2+
3+
let x = 1
4+
5+
# This is a nice comment.
6+
#
7+
# It's not associated with any particular block. It just describes things about
8+
# the contents of this file, or the broader section of code.
9+
10+
let y = 2
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
let a = {
2+
my_key: "my_key"
3+
# a comment
4+
my_other_key: "my_other_key"
5+
}

0 commit comments

Comments
 (0)