diff --git a/README.md b/README.md index 6c03954..52ee67f 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,19 @@ All boolean, string, and integer [`bsfmt.json`](#bsfmtjson-options) options are |formatInteriorWhitespace|`boolean`|`true`| All whitespace between items is reduced to exactly 1 space character and certain keywords and operators are padded with whitespace. This is a catchall property that will also disable the following rules: `insertSpaceBeforeFunctionParenthesis`, `insertSpaceBetweenEmptyCurlyBraces` `insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces`| |insertSpaceBeforeFunctionParenthesis|`boolean`|`false`| If true, a space is inserted to the left of an opening function declaration parenthesis. (i.e. `function main ()` or `function ()`). If false, all spacing is removed (i.e. `function main()` or `function()`).| |insertSpaceBetweenEmptyCurlyBraces|`boolean`|`false`| If true, empty curly braces will contain exactly 1 whitespace char (i.e. `{ }`). If false, there will be zero whitespace chars between empty curly braces (i.e. `{}`) | +|insertSpaceAfterConditionalCompileSymbol|`boolean`|`false`| If true, ensure exactly 1 space between `#` and the keyword (i.e. `# if true`). If false, remove all whitespace between `#` and the keyword (i.e. `#if true`)| |insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces|`boolean`|`true`| If true, ensure exactly 1 space after leading and before trailing curly braces. If false, REMOVE all whitespace after leading and before trailing curly braces (excluding beginning-of-line indentation spacing)| |insertSpaceBetweenAssociativeArrayLiteralKeyAndColon|`boolean`|`false`| If true, ensure exactly 1 space between an associative array literal key and its colon. If false, all space between the key and its colon will be removed | |formatSingleLineCommentType|`"singlequote", "rem", "original"`| `"original"` | Forces all single-line comments to use the same style. If 'singlequote' or falsey, all comments are preceeded by a single quote. This is the default. If `"rem"`, all comments are preceeded by `rem`. If `"original"`, the comment type is unchanged| |formatMultiLineObjectsAndArrays|`boolean`| `true`|For multi-line objects and arrays, move everything after the `{` or `[` and everything before the `}` or `]` onto a new line.`| -|sortImports|`boolean`| `false`|Sort imports alphabetically.`| +|trailingComma| `"always", "allButLast", "never", "original"` | `"original"` | Controls commas on items of multi-line arrays and associative arrays. `"always"`: every item gets a trailing comma (including the last). `"allButLast"`: every item except the last gets a comma (conventional style). `"never"`: all item commas are removed. `"original"`: commas are not modified. Has no effect on single-line arrays or AAs.| +|maxConsecutiveEmptyLines|`number`|`undefined`| Collapse runs of consecutive blank lines down to at most this many. For example, `1` collapses three blank lines in a row into one. When omitted, blank lines are not modified.| +|blankLinesBetweenFunctions|`number`|`undefined`| Ensures exactly this many blank lines between consecutive top-level `function`/`sub` declarations. When omitted, spacing between functions is not modified.| +|singleLineIf|`"collapse", "expand", "original"`|`"original"`| Controls how single-statement `if` blocks are formatted. `"collapse"`: convert a multi-line if with a single statement and no else to an inline if (e.g. `if x then y = 1`). `"expand"`: convert an inline if to a multi-line block with `end if`. `"original"`: leave if statements unchanged.| +|inlineArrayAndObjectThreshold|`number`|`0`| If set to a positive number, multi-line arrays and associative arrays whose inline representation fits within this many characters will be collapsed to a single line. Set to `0` or omit to disable.| +|removeBlankLinesAtStartOfBlock|`boolean`|`false`| If true, remove blank lines immediately after the opening of a block (`function`/`sub` body, `if`/`for`/`while` blocks, etc.).| +|alignAssignments|`boolean`|`false`| If true, align the `=` sign in consecutive simple assignment statements by padding the left-hand side with spaces. Alignment resets after a blank line or a non-assignment statement.| +|sortImports|`boolean`| `false`|Sort imports alphabetically.| ### keywordCaseOverride For more flexibility in how to format the case of keywords, you can specify the case preference for each individual keyword. Here's an example: diff --git a/src/Formatter.spec.ts b/src/Formatter.spec.ts index f3950de..90746c1 100644 --- a/src/Formatter.spec.ts +++ b/src/Formatter.spec.ts @@ -1812,6 +1812,784 @@ end sub`; }); }); + describe('maxConsecutiveEmptyLines', () => { + it('collapses multiple blank lines down to the specified max', () => { + formatEqual( + 'x = 1\n\n\n\ny = 2\n', + 'x = 1\n\ny = 2\n', + { maxConsecutiveEmptyLines: 1 } + ); + }); + + it('allows exactly the specified number of blank lines through', () => { + formatEqual( + 'x = 1\n\n\ny = 2\n', + 'x = 1\n\n\ny = 2\n', + { maxConsecutiveEmptyLines: 2 } + ); + }); + + it('removes all blank lines when set to 0', () => { + formatEqual( + 'x = 1\n\n\ny = 2\n', + 'x = 1\ny = 2\n', + { maxConsecutiveEmptyLines: 0 } + ); + }); + + it('does not affect blank lines when option is not set', () => { + formatEqual( + 'x = 1\n\n\ny = 2\n' + ); + }); + + it('works inside function bodies', () => { + formatEqualTrim(` + sub main() + x = 1 + + + y = 2 + end sub + `, ` + sub main() + x = 1 + + y = 2 + end sub + `, { maxConsecutiveEmptyLines: 1 }); + }); + + it('handles more blank lines than the max at end of file', () => { + formatEqual( + 'x = 1\n\n\n', + 'x = 1\n\n', + { maxConsecutiveEmptyLines: 1 } + ); + }); + }); + + describe('trailingComma', () => { + it('adds trailing comma to last item in a multi-line array', () => { + formatEqualTrim(` + x = [ + 1, + 2, + 3 + ] + `, ` + x = [ + 1, + 2, + 3, + ] + `, { trailingComma: 'always' }); + }); + + it('adds trailing comma to last item in a multi-line AA', () => { + formatEqualTrim(` + x = { + a: 1, + b: 2 + } + `, ` + x = { + a: 1, + b: 2, + } + `, { trailingComma: 'always' }); + }); + + it('removes all commas from items in a multi-line array', () => { + formatEqualTrim(` + x = [ + 1, + 2, + 3, + ] + `, ` + x = [ + 1 + 2 + 3 + ] + `, { trailingComma: 'never' }); + }); + + it('removes all commas from items in a multi-line AA', () => { + formatEqualTrim(` + x = { + a: 1, + b: 2, + } + `, ` + x = { + a: 1 + b: 2 + } + `, { trailingComma: 'never' }); + }); + + it('does not change trailing commas when set to original', () => { + formatEqualTrim(` + x = [ + 1, + 2, + 3, + ] + `, undefined, { trailingComma: 'original' }); + }); + + it('does not add trailing comma to single-line arrays', () => { + formatEqual('x = [1, 2, 3]\n', undefined, { trailingComma: 'always' }); + }); + + it('does not add trailing comma to single-line AAs', () => { + formatEqual('x = { a: 1, b: 2 }\n', undefined, { trailingComma: 'always' }); + }); + + it('does not affect blank arrays', () => { + formatEqual('x = []\n', undefined, { trailingComma: 'always' }); + }); + + it('does not affect blank AAs', () => { + formatEqual('x = {}\n', undefined, { trailingComma: 'always' }); + }); + + it('adds commas to all items in a comma-free multiline AA', () => { + formatEqualTrim(` + x = { + a: 1 + b: 2 + c: 3 + } + `, ` + x = { + a: 1, + b: 2, + c: 3, + } + `, { trailingComma: 'always' }); + }); + + it('removes commas from all items in a multiline AA', () => { + formatEqualTrim(` + x = { + a: 1, + b: 2, + c: 3, + } + `, ` + x = { + a: 1 + b: 2 + c: 3 + } + `, { trailingComma: 'never' }); + }); + + it('adds commas to all items in a multiline array', () => { + formatEqualTrim(` + x = [ + 1 + 2 + 3 + ] + `, ` + x = [ + 1, + 2, + 3, + ] + `, { trailingComma: 'always' }); + }); + + it('handles nested multiline AAs independently', () => { + formatEqualTrim(` + x = { + a: { + inner: 1 + inner2: 2 + } + b: 2 + } + `, ` + x = { + a: { + inner: 1, + inner2: 2, + }, + b: 2, + } + `, { trailingComma: 'always' }); + }); + + it('removes commas from all levels of nested multiline AAs', () => { + formatEqualTrim(` + x = { + a: { + inner: 1, + inner2: 2, + }, + b: 2, + } + `, ` + x = { + a: { + inner: 1 + inner2: 2 + } + b: 2 + } + `, { trailingComma: 'never' }); + }); + + it('does not add commas inside single-line nested values', () => { + formatEqualTrim(` + x = { + a: [1, 2, 3] + b: 2 + } + `, ` + x = { + a: [1, 2, 3], + b: 2, + } + `, { trailingComma: 'always' }); + }); + + it('allButLast adds commas to all items except the last in a multiline array', () => { + formatEqualTrim(` + x = [ + 1, + 2, + 3, + ] + `, ` + x = [ + 1, + 2, + 3 + ] + `, { trailingComma: 'allButLast' }); + }); + + it('allButLast adds commas to all items except the last in a comma-free multiline AA', () => { + formatEqualTrim(` + x = { + a: 1 + b: 2 + c: 3 + } + `, ` + x = { + a: 1, + b: 2, + c: 3 + } + `, { trailingComma: 'allButLast' }); + }); + + it('allButLast removes trailing comma from last item when others already have commas', () => { + formatEqualTrim(` + x = { + a: 1, + b: 2, + } + `, ` + x = { + a: 1, + b: 2 + } + `, { trailingComma: 'allButLast' }); + }); + + it('does not touch blank lines inside multiline AA', () => { + formatEqualTrim(` + x = { + a: 1 + + b: 2 + } + `, ` + x = { + a: 1, + + b: 2, + } + `, { trailingComma: 'always' }); + }); + }); + + describe('blankLinesBetweenFunctions', () => { + it('adds blank lines between consecutive functions when there are none', () => { + formatEqualTrim(` + function a() + end function + function b() + end function + `, ` + function a() + end function + + function b() + end function + `, { blankLinesBetweenFunctions: 1 }); + }); + + it('removes extra blank lines between functions', () => { + formatEqualTrim(` + function a() + end function + + + + function b() + end function + `, ` + function a() + end function + + function b() + end function + `, { blankLinesBetweenFunctions: 1 }); + }); + + it('works with subs as well as functions', () => { + formatEqualTrim(` + sub a() + end sub + sub b() + end sub + `, ` + sub a() + end sub + + sub b() + end sub + `, { blankLinesBetweenFunctions: 1 }); + }); + + it('works between a sub and a function', () => { + formatEqualTrim(` + sub a() + end sub + function b() + end function + `, ` + sub a() + end sub + + function b() + end function + `, { blankLinesBetweenFunctions: 1 }); + }); + + it('supports 2 blank lines between functions', () => { + formatEqualTrim(` + function a() + end function + function b() + end function + `, ` + function a() + end function + + + function b() + end function + `, { blankLinesBetweenFunctions: 2 }); + }); + + it('does not modify spacing when option is not set', () => { + formatEqualTrim(` + function a() + end function + function b() + end function + `); + }); + + it('does not add blank lines after the last function', () => { + formatEqualTrim(` + function a() + end function + `, undefined, { blankLinesBetweenFunctions: 1 }); + }); + + it('adds a blank line after end function with a trailing comment', () => { + formatEqualTrim(` + function a() + end function ' comment + function b() + end function + `, ` + function a() + end function ' comment + + function b() + end function + `, { blankLinesBetweenFunctions: 1 }); + }); + + it('does not add blank lines before non-function code after end function', () => { + formatEqualTrim(` + function a() + end function + + x = 1 + `, undefined, { blankLinesBetweenFunctions: 1 }); + }); + + it('handles whitespace-only lines between functions', () => { + formatEqual( + 'function a()\nend function\n \nfunction b()\nend function\n', + 'function a()\nend function\n\nfunction b()\nend function\n', + { blankLinesBetweenFunctions: 1, removeTrailingWhiteSpace: false, formatIndent: false, formatInteriorWhitespace: false } + ); + }); + }); + + describe('singleLineIf', () => { + it('collapses a simple if block to a single line', () => { + formatEqualTrim(` + if x then + y = 1 + end if + `, ` + if x then y = 1 + `, { singleLineIf: 'collapse' }); + }); + + it('expands an inline if to multi-line', () => { + formatEqualTrim(` + if x then y = 1 + `, ` + if x then + y = 1 + end if + `, { singleLineIf: 'expand' }); + }); + + it('does not collapse an if block that has an else branch', () => { + formatEqualTrim(` + if x then + y = 1 + else + y = 2 + end if + `, undefined, { singleLineIf: 'collapse' }); + }); + + it('does not collapse an if block that has an else if branch', () => { + formatEqualTrim(` + if x then + y = 1 + else if z then + y = 2 + end if + `, undefined, { singleLineIf: 'collapse' }); + }); + + it('does not collapse an if block with multiple statements', () => { + formatEqualTrim(` + if x then + y = 1 + z = 2 + end if + `, undefined, { singleLineIf: 'collapse' }); + }); + + it('does not modify if statements when set to original', () => { + formatEqualTrim(` + if x then + y = 1 + end if + `, undefined, { singleLineIf: 'original' }); + }); + + it('does not expand an already-multi-line if when set to expand', () => { + formatEqualTrim(` + if x then + y = 1 + end if + `, undefined, { singleLineIf: 'expand' }); + }); + + it('collapses an if block that is preceded by other code', () => { + formatEqualTrim(` + x = 1 + if x then + y = 1 + end if + `, ` + x = 1 + if x then y = 1 + `, { singleLineIf: 'collapse' }); + }); + + it('expands an inline if that has a trailing newline', () => { + formatEqual('if x then y = 1\n', 'if x then\n y = 1\nend if\n', { singleLineIf: 'expand' }); + }); + + it('collapses an if with whitespace between then and the newline', () => { + formatEqual('if x then \n y = 1\nend if\n', 'if x then y = 1\n', { singleLineIf: 'collapse' }); + }); + + it('collapses an if with indented end if', () => { + formatEqual('if x then\n y = 1\n end if\n', 'if x then y = 1\n', { singleLineIf: 'collapse' }); + }); + }); + + describe('inlineArrayAndObjectThreshold', () => { + it('collapses a multi-line array that fits within the character threshold', () => { + formatEqualTrim(` + x = [ + 1, + 2, + 3 + ] + `, ` + x = [1, 2, 3] + `, { inlineArrayAndObjectThreshold: 20 }); + }); + + it('collapses a multi-line AA that fits within the character threshold', () => { + formatEqualTrim(` + x = { + a: 1, + b: 2 + } + `, ` + x = { a: 1, b: 2 } + `, { inlineArrayAndObjectThreshold: 20 }); + }); + + it('does not collapse an array whose inline form exceeds the threshold', () => { + formatEqualTrim(` + x = [ + 1, + 2, + 3 + ] + `, undefined, { inlineArrayAndObjectThreshold: 5 }); + }); + + it('does not collapse when threshold is 0', () => { + formatEqualTrim(` + x = [ + 1, + 2, + 3 + ] + `, undefined, { inlineArrayAndObjectThreshold: 0 }); + }); + + it('does not collapse the outer array when it contains nested multi-line arrays', () => { + formatEqualTrim(` + x = [ + [ + 1, + 2 + ], + 3 + ] + `, ` + x = [ + [1, 2], + 3 + ] + `, { inlineArrayAndObjectThreshold: 100 }); + }); + + it('does not collapse when option is not set', () => { + formatEqualTrim(` + x = [ + 1, + 2, + 3 + ] + `); + }); + + it('does not collapse already-single-line brackets', () => { + formatEqual('x = [1, 2, 3]\n', undefined, { inlineArrayAndObjectThreshold: 50 }); + }); + + it('does not collapse outer array when it contains a nested multiline AA', () => { + formatEqualTrim(` + x = [ + { + a: 1, + b: 2 + }, + 3 + ] + `, undefined, { inlineArrayAndObjectThreshold: 5 }); + }); + }); + + describe('removeBlankLinesAtStartOfBlock', () => { + it('removes blank lines at the start of a function body', () => { + formatEqualTrim(` + function a() + + x = 1 + end function + `, ` + function a() + x = 1 + end function + `, { removeBlankLinesAtStartOfBlock: true }); + }); + + it('removes blank lines at the start of a sub body', () => { + formatEqualTrim(` + sub a() + + x = 1 + end sub + `, ` + sub a() + x = 1 + end sub + `, { removeBlankLinesAtStartOfBlock: true }); + }); + + it('removes blank lines at the start of an if block', () => { + formatEqualTrim(` + if true then + + x = 1 + end if + `, ` + if true then + x = 1 + end if + `, { removeBlankLinesAtStartOfBlock: true }); + }); + + it('removes blank lines at the start of a for loop', () => { + formatEqualTrim(` + for i = 0 to 10 + + x = 1 + end for + `, ` + for i = 0 to 10 + x = 1 + end for + `, { removeBlankLinesAtStartOfBlock: true }); + }); + + it('removes blank lines at the start of a while loop', () => { + formatEqualTrim(` + while true + + x = 1 + end while + `, ` + while true + x = 1 + end while + `, { removeBlankLinesAtStartOfBlock: true }); + }); + + it('removes multiple blank lines at the start of a block', () => { + formatEqualTrim(` + function a() + + + + x = 1 + end function + `, ` + function a() + x = 1 + end function + `, { removeBlankLinesAtStartOfBlock: true }); + }); + + it('does not remove blank lines when set to false', () => { + formatEqualTrim(` + function a() + + x = 1 + end function + `, undefined, { removeBlankLinesAtStartOfBlock: false }); + }); + + it('does not remove blank lines in the middle of a block', () => { + formatEqualTrim(` + function a() + x = 1 + + y = 2 + end function + `, undefined, { removeBlankLinesAtStartOfBlock: true }); + }); + }); + + describe('alignAssignments', () => { + it('aligns consecutive assignments by padding before the equals sign', () => { + formatEqualTrim(` + x = 1 + longName = 2 + y = 3 + `, ` + x = 1 + longName = 2 + y = 3 + `, { alignAssignments: true }); + }); + + it('resets alignment after a blank line', () => { + formatEqualTrim(` + x = 1 + longName = 2 + + a = 3 + bb = 4 + `, ` + x = 1 + longName = 2 + + a = 3 + bb = 4 + `, { alignAssignments: true }); + }); + + it('resets alignment after a non-assignment line', () => { + formatEqualTrim(` + x = 1 + longName = 2 + print "hello" + a = 3 + bb = 4 + `, ` + x = 1 + longName = 2 + print "hello" + a = 3 + bb = 4 + `, { alignAssignments: true }); + }); + + it('does not align when set to false', () => { + formatEqualTrim(` + x = 1 + longName = 2 + y = 3 + `, undefined, { alignAssignments: false }); + }); + + it('handles a single assignment (no alignment needed)', () => { + formatEqualTrim(` + x = 1 + `, undefined, { alignAssignments: true }); + }); + }); + describe('template string', () => { it('leaves template string unchanged', () => { let expected = `function getItemXML(item) diff --git a/src/Formatter.ts b/src/Formatter.ts index eec4a0b..04b640e 100644 --- a/src/Formatter.ts +++ b/src/Formatter.ts @@ -11,6 +11,13 @@ import { MultiLineItemFormatter } from './formatters/MultiLineItemFormatter'; import { TrailingWhitespaceFormatter } from './formatters/TrailingWhitespaceFormatter'; import { util } from './util'; import { SortImportsFormatter } from './formatters/SortImportsFormatter'; +import { MaxConsecutiveEmptyLinesFormatter } from './formatters/MaxConsecutiveEmptyLinesFormatter'; +import { TrailingCommaFormatter } from './formatters/TrailingCommaFormatter'; +import { BlankLinesBetweenFunctionsFormatter } from './formatters/BlankLinesBetweenFunctionsFormatter'; +import { SingleLineIfFormatter } from './formatters/SingleLineIfFormatter'; +import { InlineArrayAndObjectFormatter } from './formatters/InlineArrayAndObjectFormatter'; +import { RemoveBlankLinesAtStartOfBlockFormatter } from './formatters/RemoveBlankLinesAtStartOfBlockFormatter'; +import { AlignAssignmentsFormatter } from './formatters/AlignAssignmentsFormatter'; export class Formatter { /** @@ -40,7 +47,14 @@ export class Formatter { keywordCase: new KeywordCaseFormatter(), trailingWhitespace: new TrailingWhitespaceFormatter(), interiorWhitespace: new InteriorWhitespaceFormatter(), - sortImports: new SortImportsFormatter() + sortImports: new SortImportsFormatter(), + maxConsecutiveEmptyLines: new MaxConsecutiveEmptyLinesFormatter(), + trailingComma: new TrailingCommaFormatter(), + blankLinesBetweenFunctions: new BlankLinesBetweenFunctionsFormatter(), + singleLineIf: new SingleLineIfFormatter(), + inlineArrayAndObject: new InlineArrayAndObjectFormatter(), + removeBlankLinesAtStartOfBlock: new RemoveBlankLinesAtStartOfBlockFormatter(), + alignAssignments: new AlignAssignmentsFormatter() }; /** @@ -111,6 +125,12 @@ export class Formatter { } ); + // Must run before formatMultiLineObjectsAndArrays so that arrays/AAs that fit + // within the threshold are already single-line and won't be re-expanded. + if (options.inlineArrayAndObjectThreshold) { + tokens = this.formatters.inlineArrayAndObject.format(tokens, options); + } + if (options.formatMultiLineObjectsAndArrays) { tokens = this.formatters.multiLineItem.format(tokens); } @@ -119,6 +139,17 @@ export class Formatter { tokens = this.formatters.compositeKeyword.format(tokens, options); } + if (options.singleLineIf && options.singleLineIf !== 'original') { + tokens = this.formatters.singleLineIf.format(tokens, options, parser); + // IndentFormatter uses the parser's AST to detect inline if statements and skip + // indenting their bodies. After expanding an inline if we must re-parse so the + // updated multi-line structure is reflected and IndentFormatter indents correctly. + parser = Parser.parse( + tokens.filter(x => x.kind !== TokenKind.Whitespace), + { mode: ParseMode.BrighterScript } + ); + } + tokens = this.formatters.keywordCase.format(tokens, options); if (options.removeTrailingWhiteSpace) { @@ -133,12 +164,41 @@ export class Formatter { tokens = this.formatters.sortImports.format(tokens); } + // Runs after interior-whitespace formatting so the token stream already has + // normalized spacing (e.g. no trailing whitespace before the closing bracket). + if (options.trailingComma && options.trailingComma !== 'original') { + tokens = this.formatters.trailingComma.format(tokens, options); + } + //dedupe side-by-side Whitespace tokens util.dedupeWhitespace(tokens); if (options.formatIndent) { tokens = this.formatters.indent.format(tokens, options, parser); } + + // The following formatters operate on blank lines and must run after IndentFormatter + // because IndentFormatter.trimWhitespaceOnlyLines reduces blank lines to bare Newline + // tokens, which is the representation these formatters rely on. + + if (options.maxConsecutiveEmptyLines !== undefined) { + tokens = this.formatters.maxConsecutiveEmptyLines.format(tokens, options); + } + + if (options.blankLinesBetweenFunctions !== undefined) { + tokens = this.formatters.blankLinesBetweenFunctions.format(tokens, options); + } + + if (options.removeBlankLinesAtStartOfBlock) { + tokens = this.formatters.removeBlankLinesAtStartOfBlock.format(tokens, options); + } + + // Runs after IndentFormatter so that indentation whitespace is already in place + // and the alignment padding is added on top of the correct base indent. + if (options.alignAssignments) { + tokens = this.formatters.alignAssignments.format(tokens, options); + } + return tokens; } diff --git a/src/FormattingOptions.ts b/src/FormattingOptions.ts index 0ebec3d..2f59a54 100644 --- a/src/FormattingOptions.ts +++ b/src/FormattingOptions.ts @@ -109,6 +109,52 @@ export interface FormattingOptions { * Sort import statements alphabetically. */ sortImports?: boolean; + /** + * Collapse runs of consecutive blank lines down to at most this many blank lines. + * For example, `maxConsecutiveEmptyLines: 1` collapses three blank lines in a row into one. + * When undefined (the default), blank lines are not modified. + */ + maxConsecutiveEmptyLines?: number; + /** + * Controls commas on items of multi-line arrays and associative arrays. + * - `'always'`: ensure every item has a trailing comma (including the last) + * - `'allButLast'`: ensure every item except the last has a trailing comma + * - `'never'`: remove all item commas + * - `'original'` or omitted: leave commas unchanged + * Has no effect on single-line arrays or AAs. + */ + trailingComma?: 'always' | 'allButLast' | 'never' | 'original'; + /** + * Ensures exactly this many blank lines between consecutive top-level function/sub declarations. + * When undefined (the default), spacing between functions is not modified. + */ + blankLinesBetweenFunctions?: number; + /** + * Controls how single-statement `if` blocks are formatted. + * - `'collapse'`: convert a multi-line if with a single statement and no else to an inline if (e.g. `if x then y = 1`) + * - `'expand'`: convert an inline if to a multi-line block with `end if` + * - `'original'` or omitted: leave if statements unchanged + */ + singleLineIf?: 'collapse' | 'expand' | 'original'; + /** + * If set to a positive number, multi-line arrays and associative arrays whose inline + * representation fits within this many characters will be collapsed to a single line. + * Set to 0 or omit to disable. + */ + inlineArrayAndObjectThreshold?: number; + /** + * If true, remove blank lines immediately after the opening of a block + * (function/sub body, if/for/while blocks, etc.). + * @default false + */ + removeBlankLinesAtStartOfBlock?: boolean; + /** + * If true, align the `=` sign in consecutive simple assignment statements by + * padding the left-hand side with spaces. + * Alignment resets after a blank line or a non-assignment statement. + * @default false + */ + alignAssignments?: boolean; } export function normalizeOptions(options: FormattingOptions) { @@ -128,6 +174,11 @@ export function normalizeOptions(options: FormattingOptions) { insertSpaceBetweenAssociativeArrayLiteralKeyAndColon: false, formatMultiLineObjectsAndArrays: true, sortImports: false, + trailingComma: 'original', + singleLineIf: 'original', + inlineArrayAndObjectThreshold: 0, + removeBlankLinesAtStartOfBlock: false, + alignAssignments: false, //override defaults with the provided values ...options diff --git a/src/formatters/AlignAssignmentsFormatter.spec.ts b/src/formatters/AlignAssignmentsFormatter.spec.ts new file mode 100644 index 0000000..5e7059f --- /dev/null +++ b/src/formatters/AlignAssignmentsFormatter.spec.ts @@ -0,0 +1,50 @@ +import { expect } from 'chai'; +import { TokenKind } from 'brighterscript'; +import { AlignAssignmentsFormatter } from './AlignAssignmentsFormatter'; + +describe('AlignAssignmentsFormatter', () => { + let formatter: AlignAssignmentsFormatter; + beforeEach(() => { + formatter = new AlignAssignmentsFormatter(); + }); + + it('handles a token stream that does not end with Newline or Eof', () => { + // When the final segment has no Newline/Eof terminator, splitByLine still + // pushes the remaining tokens into lines (line 90 of AlignAssignmentsFormatter) + const tokens = [ + { kind: TokenKind.Identifier, text: 'x' }, + { kind: TokenKind.Whitespace, text: ' ' }, + { kind: TokenKind.Equal, text: '=' }, + { kind: TokenKind.Whitespace, text: ' ' }, + { kind: TokenKind.IntegerLiteral, text: '1' } + // No Newline or Eof at end + ] as any[]; + // Should not throw and should return the tokens + const result = formatter.format(tokens, { alignAssignments: true } as any); + expect(result).to.be.an('array'); + expect(result.length).to.be.greaterThan(0); + }); + + it('skips alignment when Equal token has no Whitespace token before it', () => { + // Covers the false branch of `if (prevToken && prevToken.kind === TokenKind.Whitespace)` + // when the Equal is at token index 1 with an Identifier (not Whitespace) preceding it. + const tokens = [ + { kind: TokenKind.Identifier, text: 'x' }, + { kind: TokenKind.Equal, text: '=' }, // no whitespace before = + { kind: TokenKind.Whitespace, text: ' ' }, + { kind: TokenKind.IntegerLiteral, text: '1' }, + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.Identifier, text: 'longName' }, + { kind: TokenKind.Whitespace, text: ' ' }, + { kind: TokenKind.Equal, text: '=' }, + { kind: TokenKind.Whitespace, text: ' ' }, + { kind: TokenKind.IntegerLiteral, text: '2' }, + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.Eof, text: '' } + ] as any[]; + // Both lines are simple assignments; alignGroup is called. + // Line 0's Equal is preceded by Identifier (not Whitespace), so the padding branch is skipped. + const result = formatter.format(tokens, { alignAssignments: true } as any); + expect(result).to.be.an('array'); + }); +}); diff --git a/src/formatters/AlignAssignmentsFormatter.ts b/src/formatters/AlignAssignmentsFormatter.ts new file mode 100644 index 0000000..c454995 --- /dev/null +++ b/src/formatters/AlignAssignmentsFormatter.ts @@ -0,0 +1,93 @@ +import type { Token } from 'brighterscript'; +import { TokenKind } from 'brighterscript'; +import type { FormattingOptions } from '../FormattingOptions'; + +export class AlignAssignmentsFormatter { + public format(tokens: Token[], options: FormattingOptions): Token[] { + // Split into lines for analysis + const lines = this.splitByLine(tokens); + + // Find groups of consecutive simple-assignment lines and align them + let groupStart = 0; + while (groupStart < lines.length) { + if (!this.isSimpleAssignment(lines[groupStart])) { + groupStart++; + continue; + } + let groupEnd = groupStart; + while (groupEnd + 1 < lines.length && this.isSimpleAssignment(lines[groupEnd + 1])) { + groupEnd++; + } + if (groupEnd > groupStart) { + this.alignGroup(lines.slice(groupStart, groupEnd + 1)); + } + groupStart = groupEnd + 1; + } + + return tokens; + } + + /** + * Returns true if this line is a simple `identifier = value` assignment. + * The line tokens must have: [Whitespace?] Identifier [Whitespace] Equal ... + */ + private isSimpleAssignment(lineTokens: Token[]): boolean { + const nonWs = lineTokens.filter(t => t.kind !== TokenKind.Whitespace && t.kind !== TokenKind.Newline && t.kind !== TokenKind.Eof); + return nonWs.length >= 3 && nonWs[0].kind === TokenKind.Identifier && nonWs[1].kind === TokenKind.Equal; + } + + /** + * Pads the whitespace before `=` in each line so all `=` signs are column-aligned. + * The `lines` array is a group of consecutive simple-assignment lines. + */ + private alignGroup(lines: Token[][]): void { + // Find the index of the Equal token in each line's non-whitespace token sequence + // and the text length of everything before the Equal (the LHS identifier) + const lhsLengths = lines.map(lineTokens => { + let length = 0; + for (const t of lineTokens) { + if (t.kind === TokenKind.Whitespace) { + continue; // skip leading indent + } + if (t.kind === TokenKind.Equal) { + break; + } + length += t.text.length; + } + return length; + }); + + const maxLhs = Math.max(...lhsLengths); + + for (let i = 0; i < lines.length; i++) { + const lineTokens = lines[i]; + // Find the whitespace token immediately before the Equal + for (let j = 0; j < lineTokens.length; j++) { + if (lineTokens[j].kind === TokenKind.Equal) { + const prevToken = lineTokens[j - 1]; + if (prevToken && prevToken.kind === TokenKind.Whitespace) { + const padding = maxLhs - lhsLengths[i]; + prevToken.text = ' '.repeat(1 + padding); + } + break; + } + } + } + } + + private splitByLine(tokens: Token[]): Token[][] { + const lines: Token[][] = []; + let current: Token[] = []; + for (const token of tokens) { + current.push(token); + if (token.kind === TokenKind.Newline || token.kind === TokenKind.Eof) { + lines.push(current); + current = []; + } + } + if (current.length > 0) { + lines.push(current); + } + return lines; + } +} diff --git a/src/formatters/BlankLinesBetweenFunctionsFormatter.spec.ts b/src/formatters/BlankLinesBetweenFunctionsFormatter.spec.ts new file mode 100644 index 0000000..28f6f3b --- /dev/null +++ b/src/formatters/BlankLinesBetweenFunctionsFormatter.spec.ts @@ -0,0 +1,74 @@ +import { expect } from 'chai'; +import { TokenKind } from 'brighterscript'; +import { BlankLinesBetweenFunctionsFormatter } from './BlankLinesBetweenFunctionsFormatter'; + +describe('BlankLinesBetweenFunctionsFormatter', () => { + let formatter: BlankLinesBetweenFunctionsFormatter; + beforeEach(() => { + formatter = new BlankLinesBetweenFunctionsFormatter(); + }); + + it('adds blank line between functions with tokens between end function and newline', () => { + // Covers the while loop body (endLineNewlineIndex++) by having Whitespace+Comment + // tokens between EndFunction and its Newline + const tokens = [ + { kind: TokenKind.EndFunction, text: 'end function' }, + { kind: TokenKind.Whitespace, text: ' ' }, + { kind: TokenKind.Comment, text: '\' comment' }, + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.Function, text: 'function' }, + { kind: TokenKind.Whitespace, text: ' ' }, + { kind: TokenKind.Identifier, text: 'b' }, + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.Eof, text: '' } + ] as any[]; + const result = formatter.format(tokens, { blankLinesBetweenFunctions: 1 } as any); + const text = result.map((t: any) => t.text).join(''); + expect(text).to.include('\n\n'); + }); + + it('handles whitespace-only tokens in blank lines between functions', () => { + // Covers the whitespace-in-blank-lines branch (lines 38-40) + const tokens = [ + { kind: TokenKind.EndFunction, text: 'end function' }, + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.Whitespace, text: ' ' }, // whitespace-only line + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.Function, text: 'function' }, + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.Eof, text: '' } + ] as any[]; + const result = formatter.format(tokens, { blankLinesBetweenFunctions: 1 } as any); + // The whitespace-only line should be replaced with exactly one blank line + const text = result.map((t: any) => t.text).join(''); + expect(text).to.equal('end function\n\nfunction\n'); + }); + + it('does not add blank lines when end function is followed by non-function content', () => { + // Covers the continue at line 49 (next token is not a function/sub) + const tokens = [ + { kind: TokenKind.EndFunction, text: 'end function' }, + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.Newline, text: '\n' }, // blank line + { kind: TokenKind.Identifier, text: 'x' }, + { kind: TokenKind.Eof, text: '' } + ] as any[]; + const original = tokens.map((t: any) => t.text).join(''); + const result = formatter.format(tokens, { blankLinesBetweenFunctions: 1 } as any); + const text = result.map((t: any) => t.text).join(''); + expect(text).to.equal(original); + }); + + it('continues when end function line has no Newline or Eof (optional chaining returns undefined)', () => { + // When the while loop scans past the end of the tokens array, + // tokens[endLineNewlineIndex] is undefined, and ?.kind short-circuits to undefined + const tokens = [ + { kind: TokenKind.EndFunction, text: 'end function' }, + { kind: TokenKind.Whitespace, text: ' ' } + // No Newline, no Eof — loop runs until endLineNewlineIndex >= tokens.length + ] as any[]; + const result = formatter.format(tokens, { blankLinesBetweenFunctions: 1 } as any); + // Tokens unchanged since there's no Newline-terminated line + expect(result.map((t: any) => t.text).join('')).to.equal('end function '); + }); +}); diff --git a/src/formatters/BlankLinesBetweenFunctionsFormatter.ts b/src/formatters/BlankLinesBetweenFunctionsFormatter.ts new file mode 100644 index 0000000..38585ec --- /dev/null +++ b/src/formatters/BlankLinesBetweenFunctionsFormatter.ts @@ -0,0 +1,71 @@ +import type { Token } from 'brighterscript'; +import { TokenKind } from 'brighterscript'; +import type { TokenWithStartIndex } from '../constants'; +import type { FormattingOptions } from '../FormattingOptions'; + +const FunctionEndKinds = new Set([TokenKind.EndFunction, TokenKind.EndSub]); +const FunctionStartKinds = new Set([TokenKind.Function, TokenKind.Sub]); + +export class BlankLinesBetweenFunctionsFormatter { + public format(tokens: Token[], options: FormattingOptions): Token[] { + const count = options.blankLinesBetweenFunctions!; + + for (let i = 0; i < tokens.length; i++) { + // Look for end function / end sub + if (!FunctionEndKinds.has(tokens[i].kind)) { + continue; + } + + // Find the Newline that ends the "end function" line + let endLineNewlineIndex = i + 1; + while (endLineNewlineIndex < tokens.length && tokens[endLineNewlineIndex].kind !== TokenKind.Newline && tokens[endLineNewlineIndex].kind !== TokenKind.Eof) { + endLineNewlineIndex++; + } + if (tokens[endLineNewlineIndex]?.kind !== TokenKind.Newline) { + continue; + } + + // Scan forward over blank lines and whitespace to find the next meaningful token + let scanIndex = endLineNewlineIndex + 1; + const blankTokenIndexes: number[] = []; + + while (scanIndex < tokens.length) { + const t = tokens[scanIndex]; + if (t.kind === TokenKind.Newline) { + blankTokenIndexes.push(scanIndex); + scanIndex++; + } else if (t.kind === TokenKind.Whitespace && t.text.trim() === '') { + blankTokenIndexes.push(scanIndex); + scanIndex++; + } else { + break; + } + } + + // Check if the next non-blank token is a function/sub start + const nextToken = tokens[scanIndex]; + if (!nextToken || !FunctionStartKinds.has(nextToken.kind)) { + continue; + } + + // Remove all blank tokens between the two functions + for (let j = blankTokenIndexes.length - 1; j >= 0; j--) { + tokens.splice(blankTokenIndexes[j], 1); + } + + // Insert exactly `count` blank lines (each blank line = one extra Newline) + const insertAt = endLineNewlineIndex + 1; + for (let j = 0; j < count; j++) { + tokens.splice(insertAt + j, 0, { + kind: TokenKind.Newline, + text: '\n' + } as TokenWithStartIndex); + } + + // Advance past what we just inserted + i = insertAt + count - 1; + } + + return tokens; + } +} diff --git a/src/formatters/InlineArrayAndObjectFormatter.spec.ts b/src/formatters/InlineArrayAndObjectFormatter.spec.ts new file mode 100644 index 0000000..d0afe5a --- /dev/null +++ b/src/formatters/InlineArrayAndObjectFormatter.spec.ts @@ -0,0 +1,68 @@ +import { expect } from 'chai'; +import { TokenKind } from 'brighterscript'; +import { InlineArrayAndObjectFormatter } from './InlineArrayAndObjectFormatter'; + +describe('InlineArrayAndObjectFormatter', () => { + let formatter: InlineArrayAndObjectFormatter; + beforeEach(() => { + formatter = new InlineArrayAndObjectFormatter(); + }); + + it('returns tokens unchanged when threshold is falsy', () => { + const tokens = [ + { kind: TokenKind.LeftSquareBracket, text: '[' }, + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.IntegerLiteral, text: '1' }, + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.RightSquareBracket, text: ']' } + ] as any[]; + const result = formatter.format(tokens, { inlineArrayAndObjectThreshold: 0 } as any); + expect(result).to.equal(tokens); + }); + + it('continues when there is no matching closing token for an opening bracket', () => { + // Unmatched [ — getClosingToken returns undefined → continue at line 30/31 + const tokens = [ + { kind: TokenKind.LeftSquareBracket, text: '[' }, + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.IntegerLiteral, text: '1' }, + { kind: TokenKind.Newline, text: '\n' } + // No closing bracket + ] as any[]; + const result = formatter.format(tokens, { inlineArrayAndObjectThreshold: 100 } as any); + // Should not throw; tokens returned as-is + expect(result.map((t: any) => t.text).join('')).to.equal('[\n1\n'); + }); + + it('hasNestedMultiLine continues when nested { has no matching }', () => { + // The outer [ IS multiline. Inside, there is a { with no matching } in the tokens. + // getClosingToken returns undefined for { → the continue branch (bid 12 b0) is covered. + // hasNestedMultiLine returns false, so the outer [ gets collapsed. + const tokens = [ + { kind: TokenKind.LeftSquareBracket, text: '[' }, + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.LeftCurlyBrace, text: '{' }, // no matching } + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.RightSquareBracket, text: ']' } + ] as any[]; + const result = formatter.format(tokens, { inlineArrayAndObjectThreshold: 100 } as any); + expect(result).to.be.an('array'); + }); + + it('hasNestedMultiLine returns false when nested bracket is single-line', () => { + // Outer [ is multiline. Inner [ has a matching ] with no newline inside. + // tokens.slice().some(Newline) returns false → bid 13 b1 covered. + // hasNestedMultiLine returns false, outer [ gets collapsed. + const tokens = [ + { kind: TokenKind.LeftSquareBracket, text: '[' }, // outer [ + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.LeftSquareBracket, text: '[' }, // inner [ (single-line) + { kind: TokenKind.IntegerLiteral, text: '1' }, + { kind: TokenKind.RightSquareBracket, text: ']' }, // closes inner [ + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.RightSquareBracket, text: ']' } // closes outer [ + ] as any[]; + const result = formatter.format(tokens, { inlineArrayAndObjectThreshold: 100 } as any); + expect(result).to.be.an('array'); + }); +}); diff --git a/src/formatters/InlineArrayAndObjectFormatter.ts b/src/formatters/InlineArrayAndObjectFormatter.ts new file mode 100644 index 0000000..e5577e5 --- /dev/null +++ b/src/formatters/InlineArrayAndObjectFormatter.ts @@ -0,0 +1,123 @@ +import type { Token } from 'brighterscript'; +import { TokenKind } from 'brighterscript'; +import type { FormattingOptions } from '../FormattingOptions'; +import { util } from '../util'; + +export class InlineArrayAndObjectFormatter { + public format(tokens: Token[], options: FormattingOptions): Token[] { + const threshold = options.inlineArrayAndObjectThreshold!; + if (!threshold || threshold <= 0) { + return tokens; + } + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + let openKind: TokenKind | undefined; + let closeKind: TokenKind | undefined; + + if (token.kind === TokenKind.LeftCurlyBrace) { + openKind = TokenKind.LeftCurlyBrace; + closeKind = TokenKind.RightCurlyBrace; + } else if (token.kind === TokenKind.LeftSquareBracket) { + openKind = TokenKind.LeftSquareBracket; + closeKind = TokenKind.RightSquareBracket; + } else { + continue; + } + + const closingToken = util.getClosingToken(tokens, i, openKind, closeKind); + if (!closingToken) { + continue; + } + let closeIndex = tokens.indexOf(closingToken); + + // Only process multi-line arrays/AAs + const hasNewline = tokens.slice(i + 1, closeIndex).some(t => t.kind === TokenKind.Newline); + if (!hasNewline) { + continue; + } + + // Reject if there are nested multi-line arrays/AAs inside + if (this.hasNestedMultiLine(tokens, i + 1, closeIndex)) { + continue; + } + + // Estimate the inlined character length. If it exceeds the threshold, skip. + const inlinedLength = this.estimateInlinedLength(tokens, i + 1, closeIndex); + if (inlinedLength > threshold) { + continue; + } + + // Collapse: remove all Newline tokens and the Whitespace indentation that follows each one. + let j = i + 1; + while (j < closeIndex) { + if (tokens[j].kind === TokenKind.Newline) { + tokens.splice(j, 1); + closeIndex--; + // Remove leading whitespace on the now-joined line (indentation) + while (j < closeIndex && tokens[j].kind === TokenKind.Whitespace) { + tokens.splice(j, 1); + closeIndex--; + } + } else { + j++; + } + } + } + + return tokens; + } + + /** + * Checks whether any nested [ or { within [startIndex, endIndex) itself spans multiple lines. + */ + private hasNestedMultiLine(tokens: Token[], startIndex: number, endIndex: number): boolean { + for (let i = startIndex; i < endIndex; i++) { + const t = tokens[i]; + let openKind: TokenKind | undefined; + let closeKind: TokenKind | undefined; + if (t.kind === TokenKind.LeftCurlyBrace) { + openKind = TokenKind.LeftCurlyBrace; + closeKind = TokenKind.RightCurlyBrace; + } else if (t.kind === TokenKind.LeftSquareBracket) { + openKind = TokenKind.LeftSquareBracket; + closeKind = TokenKind.RightSquareBracket; + } else { + continue; + } + const closer = util.getClosingToken(tokens, i, openKind, closeKind); + if (!closer) { + continue; + } + const closerIdx = tokens.indexOf(closer); + if (tokens.slice(i + 1, closerIdx).some(x => x.kind === TokenKind.Newline)) { + return true; + } + } + return false; + } + + /** + * Estimates the character length of the inlined form of the content between brackets. + * Skips newlines and the indentation whitespace that follows them. + * Adds 2 for the surrounding brackets. + */ + private estimateInlinedLength(tokens: Token[], fromIdx: number, toIdx: number): number { + let length = 2; // surrounding brackets + let prevWasNewline = false; + for (let i = fromIdx; i < toIdx; i++) { + const t = tokens[i]; + if (t.kind === TokenKind.Newline) { + prevWasNewline = true; + continue; + } + if (t.kind === TokenKind.Whitespace && prevWasNewline) { + // Leading indentation after a newline — skip + continue; + } + prevWasNewline = false; + length += t.text.length; + } + return length; + } +} diff --git a/src/formatters/MaxConsecutiveEmptyLinesFormatter.ts b/src/formatters/MaxConsecutiveEmptyLinesFormatter.ts new file mode 100644 index 0000000..0940862 --- /dev/null +++ b/src/formatters/MaxConsecutiveEmptyLinesFormatter.ts @@ -0,0 +1,28 @@ +import type { Token } from 'brighterscript'; +import { TokenKind } from 'brighterscript'; +import type { FormattingOptions } from '../FormattingOptions'; + +export class MaxConsecutiveEmptyLinesFormatter { + public format(tokens: Token[], options: FormattingOptions): Token[] { + const max = options.maxConsecutiveEmptyLines!; + // A blank line = one extra newline. So "max blank lines" = max+1 consecutive Newline tokens. + const allowedNewlines = max + 1; + + const result: Token[] = []; + let consecutiveNewlines = 0; + + for (const token of tokens) { + if (token.kind === TokenKind.Newline) { + consecutiveNewlines++; + if (consecutiveNewlines <= allowedNewlines) { + result.push(token); + } + // else: drop this extra newline + } else { + consecutiveNewlines = 0; + result.push(token); + } + } + return result; + } +} diff --git a/src/formatters/RemoveBlankLinesAtStartOfBlockFormatter.spec.ts b/src/formatters/RemoveBlankLinesAtStartOfBlockFormatter.spec.ts new file mode 100644 index 0000000..6f8d788 --- /dev/null +++ b/src/formatters/RemoveBlankLinesAtStartOfBlockFormatter.spec.ts @@ -0,0 +1,22 @@ +import { expect } from 'chai'; +import { TokenKind } from 'brighterscript'; +import { RemoveBlankLinesAtStartOfBlockFormatter } from './RemoveBlankLinesAtStartOfBlockFormatter'; + +describe('RemoveBlankLinesAtStartOfBlockFormatter', () => { + let formatter: RemoveBlankLinesAtStartOfBlockFormatter; + beforeEach(() => { + formatter = new RemoveBlankLinesAtStartOfBlockFormatter(); + }); + + it('continues when a block-opener token has no following Newline', () => { + // When the block opener (e.g. Function) is at the end of the token stream with + // no Newline after it, the formatter should continue without crashing (line 37) + const tokens = [ + { kind: TokenKind.Function, text: 'function' } + // No Newline or Eof after — newlineIndex will be >= tokens.length + ] as any[]; + const result = formatter.format(tokens, {} as any); + expect(result).to.be.an('array'); + expect(result.length).to.equal(1); + }); +}); diff --git a/src/formatters/RemoveBlankLinesAtStartOfBlockFormatter.ts b/src/formatters/RemoveBlankLinesAtStartOfBlockFormatter.ts new file mode 100644 index 0000000..000047c --- /dev/null +++ b/src/formatters/RemoveBlankLinesAtStartOfBlockFormatter.ts @@ -0,0 +1,48 @@ +import type { Token } from 'brighterscript'; +import { TokenKind } from 'brighterscript'; +import type { FormattingOptions } from '../FormattingOptions'; + +/** + * Token kinds that open an indented block. + * A blank line immediately after the Newline ending these lines will be removed. + */ +const BlockOpenerKinds = new Set([ + TokenKind.Function, + TokenKind.Sub, + TokenKind.If, + TokenKind.For, + TokenKind.ForEach, + TokenKind.While, + TokenKind.Try, + TokenKind.Else, + TokenKind.HashIf, + TokenKind.HashElse, + TokenKind.HashElseIf +]); + +export class RemoveBlankLinesAtStartOfBlockFormatter { + public format(tokens: Token[], _options: FormattingOptions): Token[] { + for (let i = 0; i < tokens.length; i++) { + if (!BlockOpenerKinds.has(tokens[i].kind)) { + continue; + } + + // Find the Newline that ends this line + let newlineIndex = i + 1; + while (newlineIndex < tokens.length && tokens[newlineIndex].kind !== TokenKind.Newline && tokens[newlineIndex].kind !== TokenKind.Eof) { + newlineIndex++; + } + if (tokens[newlineIndex]?.kind !== TokenKind.Newline) { + continue; + } + + // After IndentFormatter, blank lines are bare Newline tokens with no surrounding whitespace. + // Remove any consecutive bare Newline tokens immediately after the block opener's newline. + let j = newlineIndex + 1; + while (j < tokens.length && tokens[j].kind === TokenKind.Newline) { + tokens.splice(j, 1); + } + } + return tokens; + } +} diff --git a/src/formatters/SingleLineIfFormatter.spec.ts b/src/formatters/SingleLineIfFormatter.spec.ts new file mode 100644 index 0000000..b5fc995 --- /dev/null +++ b/src/formatters/SingleLineIfFormatter.spec.ts @@ -0,0 +1,156 @@ +import { expect } from 'chai'; +import { TokenKind } from 'brighterscript'; +import { SingleLineIfFormatter } from './SingleLineIfFormatter'; + +describe('SingleLineIfFormatter', () => { + let formatter: SingleLineIfFormatter; + beforeEach(() => { + formatter = new SingleLineIfFormatter(); + }); + + const fakeParser = { + ast: { + walk: (_visitor: any, _opts: any) => { /* no-op */ } + } + } as any; + + describe('format()', () => { + it('returns tokens unchanged when mode is falsy', () => { + const tokens = [{ kind: TokenKind.Identifier, text: 'x' }] as any[]; + const result = formatter.format(tokens, { singleLineIf: undefined } as any, fakeParser); + expect(result).to.equal(tokens); + }); + + it('returns tokens unchanged when mode is truthy but unrecognized', () => { + // Covers the false branch of else-if (mode === 'collapse') when mode is neither expand nor collapse + const tokens = [{ kind: TokenKind.Identifier, text: 'x' }] as any[]; + const result = formatter.format(tokens, { singleLineIf: 'bogus' as any } as any, fakeParser); + expect(result).to.equal(tokens); + }); + + it('evaluates thenBranch?.statements when thenBranch is undefined', () => { + // Covers the cond-expr branches for s.thenBranch?.statements?.length + // by injecting a fake IfStatement (via constructor.name) into the walk + const fakeStmt = Object.assign( + Object.create({ constructor: { name: 'IfStatement' } }), + { tokens: { endIf: {}, if: {} }, elseBranch: undefined, thenBranch: undefined } + ); + const customParser = { ast: { walk: (v: any, _o: any) => v(fakeStmt) } }; + const tokens = [{ kind: TokenKind.Identifier, text: 'x' }] as any[]; + const result = formatter.format(tokens, { singleLineIf: 'collapse' } as any, customParser as any); + expect(result).to.equal(tokens); + }); + }); + + describe('isStandaloneIf()', () => { + it('returns false when the if token is not found in the tokens array', () => { + const ifToken = { kind: TokenKind.If, text: 'if' }; + const stmt = { tokens: { if: ifToken } } as any; + const tokens = [{ kind: TokenKind.Identifier, text: 'x' }] as any[]; // ifToken not in tokens + const result = (formatter as any).isStandaloneIf(tokens, stmt); + expect(result).to.be.false; + }); + }); + + describe('expand()', () => { + it('returns early when thenToken is undefined', () => { + const tokens = [{ kind: TokenKind.If, text: 'if' }] as any[]; + const stmt = { tokens: { then: undefined } } as any; + // Should return without modifying tokens + (formatter as any).expand(tokens, stmt, {} as any); + expect(tokens.length).to.equal(1); + }); + + it('returns early when thenToken is not found in tokens', () => { + const thenToken = { kind: TokenKind.Then, text: 'then' }; + const tokens = [{ kind: TokenKind.If, text: 'if' }] as any[]; // thenToken not in tokens + const stmt = { tokens: { then: thenToken } } as any; + (formatter as any).expand(tokens, stmt, {} as any); + expect(tokens.length).to.equal(1); + }); + + it('inserts a Newline when the token after then is not Whitespace', () => { + // then immediately followed by a body token (no whitespace) + const thenToken = { kind: TokenKind.Then, text: 'then' }; + const bodyToken = { kind: TokenKind.Identifier, text: 'y' }; + const eofToken = { kind: TokenKind.Eof, text: '' }; + const tokens = [thenToken, bodyToken, eofToken] as any[]; + const stmt = { tokens: { then: thenToken } } as any; + (formatter as any).expand(tokens, stmt, { compositeKeywords: 'split' } as any); + // A Newline should be spliced in at index 1 + expect(tokens[1].kind).to.equal(TokenKind.Newline); + }); + + it('covers afterThen undefined and lineEnder undefined when thenToken is last token', () => { + // afterThen?.kind: tokens[thenIdx+1] is undefined (afterThen is undefined) + // lineEnder: tokens[lineEndIdx] is also undefined after exhausting the array + const thenToken = { kind: TokenKind.Then, text: 'then' }; + const tokens = [thenToken] as any[]; + const stmt = { tokens: { then: thenToken } } as any; + (formatter as any).expand(tokens, stmt, {} as any); + // A Newline should be spliced at index 1 (afterThen was undefined → else branch) + expect(tokens[1].kind).to.equal(TokenKind.Newline); + }); + + it('uses "endif" text when compositeKeywords is combine', () => { + // Covers the ternary true branch: compositeKeywords === 'combine' → 'endif' + const thenToken = { kind: TokenKind.Then, text: 'then' }; + const bodyToken = { kind: TokenKind.Identifier, text: 'y' }; + const eofToken = { kind: TokenKind.Eof, text: '' }; + const tokens = [thenToken, bodyToken, eofToken] as any[]; + const stmt = { tokens: { then: thenToken } } as any; + (formatter as any).expand(tokens, stmt, { compositeKeywords: 'combine' } as any); + const endIfTok = tokens.find((t: any) => t.kind === TokenKind.EndIf); + expect(endIfTok.text).to.equal('endif'); + }); + }); + + describe('collapse()', () => { + it('returns early when thenToken is undefined', () => { + const tokens = [{ kind: TokenKind.If, text: 'if' }] as any[]; + const stmt = { tokens: { then: undefined, endIf: {} } } as any; + (formatter as any).collapse(tokens, stmt); + expect(tokens.length).to.equal(1); + }); + + it('returns early when endIfToken is undefined', () => { + const tokens = [{ kind: TokenKind.If, text: 'if' }] as any[]; + const stmt = { tokens: { then: {}, endIf: undefined } } as any; + (formatter as any).collapse(tokens, stmt); + expect(tokens.length).to.equal(1); + }); + + it('returns early when thenToken is not found in tokens', () => { + const thenToken = { kind: TokenKind.Then, text: 'then' }; + const endIfToken = { kind: TokenKind.EndIf, text: 'end if' }; + const tokens = [endIfToken] as any[]; // thenToken not in tokens + const stmt = { tokens: { then: thenToken, endIf: endIfToken } } as any; + (formatter as any).collapse(tokens, stmt); + expect(tokens.length).to.equal(1); + }); + + it('returns early when the token after then is not a Newline (after skipping whitespace)', () => { + // then is followed by a non-Newline, non-Whitespace token + const thenToken = { kind: TokenKind.Then, text: 'then' }; + const bodyToken = { kind: TokenKind.Identifier, text: 'y' }; + const endIfToken = { kind: TokenKind.EndIf, text: 'end if' }; + const tokens = [thenToken, bodyToken, endIfToken] as any[]; + const stmt = { tokens: { then: thenToken, endIf: endIfToken } } as any; + (formatter as any).collapse(tokens, stmt); + expect(tokens.length).to.equal(3); // unchanged + }); + + it('returns early when there is no Newline before endIf (unexpected structure)', () => { + // then is followed by Newline, then endIf is immediately after body (no Newline before endIf) + const thenToken = { kind: TokenKind.Then, text: 'then' }; + const newlineToken = { kind: TokenKind.Newline, text: '\n' }; + const bodyToken = { kind: TokenKind.Identifier, text: 'y' }; // body without line terminator + const endIfToken = { kind: TokenKind.EndIf, text: 'end if' }; + // No Newline between bodyToken and endIfToken + const tokens = [thenToken, newlineToken, bodyToken, endIfToken] as any[]; + const stmt = { tokens: { then: thenToken, endIf: endIfToken } } as any; + (formatter as any).collapse(tokens, stmt); + expect(tokens.length).to.equal(4); // unchanged + }); + }); +}); diff --git a/src/formatters/SingleLineIfFormatter.ts b/src/formatters/SingleLineIfFormatter.ts new file mode 100644 index 0000000..469aef6 --- /dev/null +++ b/src/formatters/SingleLineIfFormatter.ts @@ -0,0 +1,186 @@ +import type { Token, Parser, IfStatement } from 'brighterscript'; +import { createVisitor, createToken, TokenKind, WalkMode } from 'brighterscript'; +import type { TokenWithStartIndex } from '../constants'; +import type { FormattingOptions } from '../FormattingOptions'; + +export class SingleLineIfFormatter { + public format(tokens: Token[], options: FormattingOptions, parser: Parser): Token[] { + const mode = options.singleLineIf; + if (!mode || mode === 'original') { + return tokens; + } + + // Collect all if statements from the AST + const ifStatements: IfStatement[] = []; + parser.ast.walk(createVisitor({ + IfStatement: (stmt) => { + ifStatements.push(stmt); + } + }), { walkMode: WalkMode.visitAllRecursive }); + + if (mode === 'expand') { + // Inline ifs have no endIf token. Process in reverse to keep indices stable. + const inlineIfs = ifStatements.filter(s => !s.tokens.endIf && this.isStandaloneIf(tokens, s)); + for (let i = inlineIfs.length - 1; i >= 0; i--) { + this.expand(tokens, inlineIfs[i], options); + } + } else if (mode === 'collapse') { + // Collapsible: multi-line, single statement, no else, and not an `else if` branch + const collapsible = ifStatements.filter(s => s.tokens.endIf && !s.elseBranch && s.thenBranch?.statements?.length === 1 && this.isStandaloneIf(tokens, s)); + for (let i = collapsible.length - 1; i >= 0; i--) { + this.collapse(tokens, collapsible[i]); + } + } + + return tokens; + } + + /** + * Returns false if this if statement is an `else if` branch + * (i.e. the token before `if` on the same line is `else`). + */ + private isStandaloneIf(tokens: Token[], stmt: IfStatement): boolean { + const ifIdx = tokens.indexOf(stmt.tokens.if); + if (ifIdx === -1) { + return false; + } + for (let i = ifIdx - 1; i >= 0; i--) { + const t = tokens[i]; + if (t.kind === TokenKind.Newline || t.kind === TokenKind.Eof) { + break; + } + if (t.kind === TokenKind.Else) { + return false; + } + } + return true; + } + + /** + * Expand: `if x then y = 1` → multi-line block with `end if` + * + * Replaces the whitespace after `then` with `\n`, then appends `\n end if [\n]` + * after the body. IndentFormatter handles indentation. + */ + private expand(tokens: Token[], stmt: IfStatement, options: FormattingOptions): void { + const thenToken = stmt.tokens.then; + if (!thenToken) { + return; + } + const thenIdx = tokens.indexOf(thenToken); + if (thenIdx === -1) { + return; + } + + // Replace whitespace after `then` with a newline (or insert one if missing) + const afterThen = tokens[thenIdx + 1]; + if (afterThen?.kind === TokenKind.Whitespace) { + afterThen.text = '\n'; + afterThen.kind = TokenKind.Newline; + } else { + tokens.splice(thenIdx + 1, 0, { + kind: TokenKind.Newline, + text: '\n' + } as TokenWithStartIndex); + } + + // Walk forward to find the end of the body line (the \n or EOF that terminates it) + let lineEndIdx = thenIdx + 2; + while (lineEndIdx < tokens.length && tokens[lineEndIdx].kind !== TokenKind.Newline && tokens[lineEndIdx].kind !== TokenKind.Eof) { + lineEndIdx++; + } + + const endIfText = options.compositeKeywords === 'combine' ? 'endif' : 'end if'; + // Give the synthetic EndIf a range on a far-future line so IndentFormatter + // does not classify the re-parsed if as an inline single-line if. + const endIfToken = createToken(TokenKind.EndIf, endIfText, { + start: { line: 999999, character: 0 }, + end: { line: 999999, character: endIfText.length } + }); + const lineEnder = tokens[lineEndIdx]; + + if (lineEnder?.kind === TokenKind.Eof) { + // No trailing newline — insert \n + end if before EOF + tokens.splice(lineEndIdx, 0, + { kind: TokenKind.Newline, text: '\n' } as TokenWithStartIndex, + endIfToken + ); + } else { + // lineEnder is \n — insert end if + \n after it + tokens.splice(lineEndIdx + 1, 0, + endIfToken, + { kind: TokenKind.Newline, text: '\n' } as TokenWithStartIndex + ); + } + } + + /** + * Collapse: + * ``` + * if x then + * y = 1 + * end if + * ``` + * → `if x then y = 1` + * + * Removes the \n after `then`, the body's indentation, the \n before `end if`, + * and the `end if` token itself. Keeps the \n that was *after* `end if` as the + * line-ender for the resulting inline if. + */ + private collapse(tokens: Token[], stmt: IfStatement): void { + const thenToken = stmt.tokens.then; + const endIfToken = stmt.tokens.endIf; + if (!thenToken || !endIfToken) { + return; + } + + const thenIdx = tokens.indexOf(thenToken); + const endIfIdx = tokens.indexOf(endIfToken); + if (thenIdx === -1 || endIfIdx === -1) { + return; + } + + // Find the \n immediately after `then` (may have whitespace between then and \n, though unusual) + let newlineAfterThenIdx = thenIdx + 1; + while (newlineAfterThenIdx < endIfIdx && tokens[newlineAfterThenIdx].kind === TokenKind.Whitespace) { + newlineAfterThenIdx++; + } + if (tokens[newlineAfterThenIdx].kind !== TokenKind.Newline) { + return; // not a multi-line if + } + + // Find the \n immediately before `end if` (the body's line-ender) + let newlineBeforeEndIfIdx = endIfIdx - 1; + while (newlineBeforeEndIfIdx > newlineAfterThenIdx && tokens[newlineBeforeEndIfIdx].kind === TokenKind.Whitespace) { + newlineBeforeEndIfIdx--; + } + if (tokens[newlineBeforeEndIfIdx].kind !== TokenKind.Newline) { + return; // unexpected structure + } + + // Perform all deletions in reverse index order (highest first) to keep indices stable: + + // 1. Remove `end if` token + tokens.splice(endIfIdx, 1); + + // 2. Remove the \n before `end if` (body line-ender) + // endIfIdx didn't shift because we only removed endIfIdx itself which is >= endIfIdx + tokens.splice(newlineBeforeEndIfIdx, 1); + + // 3. Remove body indentation whitespace (tokens between \n-after-then+1 and body start) + // Both splices above were at indices > newlineAfterThenIdx, so it's still valid. + let indentIdx = newlineAfterThenIdx + 1; + while (indentIdx < tokens.length && tokens[indentIdx].kind === TokenKind.Whitespace) { + tokens.splice(indentIdx, 1); + } + + // 4. Remove the \n after `then` + tokens.splice(newlineAfterThenIdx, 1); + + // 5. Insert a space between `then` and the body (now at newlineAfterThenIdx position) + tokens.splice(newlineAfterThenIdx, 0, { + kind: TokenKind.Whitespace, + text: ' ' + } as TokenWithStartIndex); + } +} diff --git a/src/formatters/TrailingCommaFormatter.spec.ts b/src/formatters/TrailingCommaFormatter.spec.ts new file mode 100644 index 0000000..4f4abd3 --- /dev/null +++ b/src/formatters/TrailingCommaFormatter.spec.ts @@ -0,0 +1,59 @@ +import { expect } from 'chai'; +import { TokenKind } from 'brighterscript'; +import { TrailingCommaFormatter } from './TrailingCommaFormatter'; + +describe('TrailingCommaFormatter', () => { + let formatter: TrailingCommaFormatter; + beforeEach(() => { + formatter = new TrailingCommaFormatter(); + }); + + it('returns tokens unchanged when mode is falsy', () => { + const tokens = [ + { kind: TokenKind.Identifier, text: 'x' } + ] as any[]; + const result = formatter.format(tokens, { trailingComma: undefined } as any); + expect(result).to.equal(tokens); + }); + + it('continues when there is no matching closing token for an opening bracket', () => { + // Unmatched [ — getClosingToken returns undefined → continue at line 34 + const tokens = [ + { kind: TokenKind.LeftSquareBracket, text: '[' }, + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.IntegerLiteral, text: '1' }, + { kind: TokenKind.Newline, text: '\n' } + // No RightSquareBracket + ] as any[]; + // Should not throw and should return tokens unchanged + const result = formatter.format(tokens, { trailingComma: 'always' } as any); + expect(result.map((t: any) => t.text).join('')).to.equal('[\n1\n'); + }); + + it('findLastContentTokenBefore skips whitespace tokens before content', () => { + // Line with trailing whitespace: [content, Whitespace, Newline] + // Scanning backwards from Newline hits Whitespace → continue (line 136/137) + const tokens = [ + { kind: TokenKind.LeftSquareBracket, text: '[' }, + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.IntegerLiteral, text: '1' }, + { kind: TokenKind.Whitespace, text: ' ' }, // trailing whitespace on line + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.RightSquareBracket, text: ']' } + ] as any[]; + // With 'always', the formatter should still find '1' as the last content and add comma + const result = formatter.format(tokens, { trailingComma: 'always' } as any); + const text = result.map((t: any) => t.text).join(''); + // '1' is the last content, a comma should be inserted after it + expect(text).to.include(','); + }); + + it('findLastContentTokenBefore returns undefined when all preceding tokens are whitespace', () => { + // Directly call the private method with tokens where only whitespace precedes endIndex + const tokens = [ + { kind: TokenKind.Whitespace, text: ' ' } + ] as any[]; + const result = (formatter as any).findLastContentTokenBefore(tokens, 1); + expect(result).to.be.undefined; + }); +}); diff --git a/src/formatters/TrailingCommaFormatter.ts b/src/formatters/TrailingCommaFormatter.ts new file mode 100644 index 0000000..57d9f23 --- /dev/null +++ b/src/formatters/TrailingCommaFormatter.ts @@ -0,0 +1,145 @@ +import type { Token } from 'brighterscript'; +import { TokenKind } from 'brighterscript'; +import type { TokenWithStartIndex } from '../constants'; +import type { FormattingOptions } from '../FormattingOptions'; +import { util } from '../util'; + +export class TrailingCommaFormatter { + public format(tokens: Token[], options: FormattingOptions): Token[] { + const mode = options.trailingComma; + if (!mode || mode === 'original') { + return tokens; + } + + // Collect all opening bracket token objects (by reference, not index) + // so indices remain valid after splicing during inner-collection processing + const openTokens: Array<{ token: Token; openKind: TokenKind; closeKind: TokenKind }> = []; + for (const token of tokens) { + if (token.kind === TokenKind.LeftCurlyBrace) { + openTokens.push({ token: token, openKind: TokenKind.LeftCurlyBrace, closeKind: TokenKind.RightCurlyBrace }); + } else if (token.kind === TokenKind.LeftSquareBracket) { + openTokens.push({ token: token, openKind: TokenKind.LeftSquareBracket, closeKind: TokenKind.RightSquareBracket }); + } + } + + // Process innermost collections first (reverse order) so that splicing inside a nested + // collection does not shift the opening-bracket index of outer collections. + for (let ci = openTokens.length - 1; ci >= 0; ci--) { + const { token: openToken, openKind, closeKind } = openTokens[ci]; + + // Re-resolve index each time — inner modifications may have shifted outer tokens + const openIndex = tokens.indexOf(openToken); + const closingToken = util.getClosingToken(tokens, openIndex, openKind, closeKind); + if (!closingToken) { + continue; + } + const closeIndex = tokens.indexOf(closingToken); + + // Only process multiline collections + const isMultiLine = tokens.slice(openIndex, closeIndex).some(t => t.kind === TokenKind.Newline); + if (!isMultiLine) { + continue; + } + + // Collect the last-content-token index for each item line at depth 0. + // Gathering all of them first lets us identify which one is the trailing item. + const itemEnds: Array<{ contentIdx: number; hasComma: boolean }> = []; + let depth = 0; + + for (let i = openIndex + 1; i < closeIndex; i++) { + const token = tokens[i]; + + // Track nesting depth so we only operate on direct items of this collection + if ( + token.kind === TokenKind.LeftCurlyBrace || + token.kind === TokenKind.LeftSquareBracket || + token.kind === TokenKind.LeftParen + ) { + depth++; + continue; + } + + if ( + token.kind === TokenKind.RightCurlyBrace || + token.kind === TokenKind.RightSquareBracket || + token.kind === TokenKind.RightParen + ) { + depth--; + // fall through — need to reach the newline check below + } + + if (depth !== 0 || token.kind !== TokenKind.Newline) { + continue; + } + + // Find the last non-whitespace token on this line (stops at the previous newline + // so blank lines return undefined) + const lastContentIdx = this.findLastContentTokenBefore(tokens, i); + if (lastContentIdx === undefined || lastContentIdx < openIndex) { + continue; + } + + const lastContent = tokens[lastContentIdx]; + + // Skip the line that only contains the opening bracket, and comment-only lines + if (lastContent.kind === openKind || lastContent.kind === TokenKind.Comment) { + continue; + } + + itemEnds.push({ + contentIdx: lastContentIdx, + hasComma: lastContent.kind === TokenKind.Comma + }); + } + + // Decide what to do with each item now that we know which is last + const modifications: Array<{ index: number; action: 'insert' | 'delete' }> = []; + for (let idx = 0; idx < itemEnds.length; idx++) { + const { contentIdx, hasComma } = itemEnds[idx]; + const isLast = idx === itemEnds.length - 1; + + const wantComma = + mode === 'always' || + (mode === 'allButLast' && !isLast); + + if (wantComma && !hasComma) { + modifications.push({ index: contentIdx + 1, action: 'insert' }); + } else if (!wantComma && hasComma) { + modifications.push({ index: contentIdx, action: 'delete' }); + } + } + + // Apply in reverse order so earlier indices are not shifted by later splices + for (const mod of [...modifications].reverse()) { + if (mod.action === 'insert') { + tokens.splice(mod.index, 0, { + kind: TokenKind.Comma, + text: ',' + } as TokenWithStartIndex); + } else { + tokens.splice(mod.index, 1); + } + } + } + + return tokens; + } + + /** + * Returns the index of the last non-whitespace token before `endIndex` on the same line. + * Returns `undefined` for blank lines (hits another Newline before finding any content). + */ + private findLastContentTokenBefore(tokens: Token[], endIndex: number): number | undefined { + for (let i = endIndex - 1; i >= 0; i--) { + const t = tokens[i]; + if (t.kind === TokenKind.Whitespace) { + continue; + } + if (t.kind === TokenKind.Newline) { + return undefined; // blank line + } + return i; + } + return undefined; + } +}