From 78f49ee570b420dd91db9f0dff85e26ba2bb9b6d Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Sat, 28 Mar 2026 19:44:56 -0300 Subject: [PATCH 1/7] Add 7 new formatting options - maxConsecutiveEmptyLines: collapse runs of blank lines to a configurable max - trailingComma: add or remove trailing commas in multi-line arrays/AAs - blankLinesBetweenFunctions: enforce exact blank lines between function/sub declarations - singleLineIf: collapse or expand single-statement if blocks - inlineArrayAndObjectThreshold: collapse short multi-line arrays/AAs to a single line - removeBlankLinesAtStartOfBlock: remove blank lines at the start of function/if/for/while bodies - alignAssignments: align = signs in consecutive assignment blocks Each option is covered by tests written before implementation (TDD). Pipeline ordering comments explain why each formatter runs where it does relative to IndentFormatter and MultiLineItemFormatter. Co-Authored-By: Claude Sonnet 4.6 --- src/Formatter.spec.ts | 542 ++++++++++++++++++ src/Formatter.ts | 62 +- src/FormattingOptions.ts | 50 ++ src/formatters/AlignAssignmentsFormatter.ts | 93 +++ .../BlankLinesBetweenFunctionsFormatter.ts | 71 +++ .../InlineArrayAndObjectFormatter.ts | 123 ++++ .../MaxConsecutiveEmptyLinesFormatter.ts | 28 + ...RemoveBlankLinesAtStartOfBlockFormatter.ts | 48 ++ src/formatters/SingleLineIfFormatter.ts | 186 ++++++ src/formatters/TrailingCommaFormatter.ts | 78 +++ 10 files changed, 1280 insertions(+), 1 deletion(-) create mode 100644 src/formatters/AlignAssignmentsFormatter.ts create mode 100644 src/formatters/BlankLinesBetweenFunctionsFormatter.ts create mode 100644 src/formatters/InlineArrayAndObjectFormatter.ts create mode 100644 src/formatters/MaxConsecutiveEmptyLinesFormatter.ts create mode 100644 src/formatters/RemoveBlankLinesAtStartOfBlockFormatter.ts create mode 100644 src/formatters/SingleLineIfFormatter.ts create mode 100644 src/formatters/TrailingCommaFormatter.ts diff --git a/src/Formatter.spec.ts b/src/Formatter.spec.ts index f3950de..fc71da3 100644 --- a/src/Formatter.spec.ts +++ b/src/Formatter.spec.ts @@ -1812,6 +1812,548 @@ 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 trailing comma from last item in a multi-line array', () => { + formatEqualTrim(` + x = [ + 1, + 2, + 3, + ] + `, ` + x = [ + 1, + 2, + 3 + ] + `, { trailingComma: 'never' }); + }); + + it('removes trailing comma from last item 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' }); + }); + }); + + 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 }); + }); + }); + + 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' }); + }); + }); + + 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 + ] + `); + }); + }); + + 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..40aeabf 100644 --- a/src/FormattingOptions.ts +++ b/src/FormattingOptions.ts @@ -109,6 +109,51 @@ 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 trailing commas on the last item of multi-line arrays and associative arrays. + * - `'always'`: ensure a trailing comma is present + * - `'never'`: remove any trailing comma + * - `'original'` or omitted: leave trailing commas unchanged + * Has no effect on single-line arrays or AAs. + */ + trailingComma?: 'always' | '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 +173,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.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.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.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.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.ts b/src/formatters/SingleLineIfFormatter.ts new file mode 100644 index 0000000..d4ceca9 --- /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.ts b/src/formatters/TrailingCommaFormatter.ts new file mode 100644 index 0000000..7cd18e9 --- /dev/null +++ b/src/formatters/TrailingCommaFormatter.ts @@ -0,0 +1,78 @@ +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; + } + + 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; + } + + // Only process multi-line arrays/AAs (ones that contain a Newline between brackets) + const closingToken = util.getClosingToken(tokens, i, openKind, closeKind); + if (!closingToken) { + continue; + } + const closeIndex = tokens.indexOf(closingToken); + const isMultiLine = tokens.slice(i, closeIndex).some(t => t.kind === TokenKind.Newline); + if (!isMultiLine) { + continue; + } + + // Find the last non-whitespace, non-newline token before the closing bracket + const lastItemToken = this.getPreviousContentToken(tokens, closeIndex); + if (!lastItemToken) { + continue; + } + const lastItemIndex = tokens.indexOf(lastItemToken); + + // Skip empty collections + if (lastItemToken.kind === openKind) { + continue; + } + + if (mode === 'always') { + if (lastItemToken.kind !== TokenKind.Comma) { + // Insert a comma after the last item token + tokens.splice(lastItemIndex + 1, 0, { + kind: TokenKind.Comma, + text: ',' + } as TokenWithStartIndex); + } + } else if (mode === 'never') { + if (lastItemToken.kind === TokenKind.Comma) { + tokens.splice(lastItemIndex, 1); + } + } + } + return tokens; + } + + /** Like getPreviousNonWhitespaceToken but also skips Newline tokens */ + private getPreviousContentToken(tokens: Token[], startIndex: number): Token | undefined { + for (let i = startIndex - 1; i >= 0; i--) { + const t = tokens[i]; + if (t.kind !== TokenKind.Whitespace && t.kind !== TokenKind.Newline) { + return t; + } + } + } +} From 1249f151ee75396528bf7275ad8efa5a34ee1aec Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Sat, 28 Mar 2026 20:05:44 -0300 Subject: [PATCH 2/7] Extend trailingComma to handle all items in multiline AAs/arrays, add 'allButLast' mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'always' and 'never' now apply to every item in a multiline collection, not just the trailing one — enabling conversion between the BrightScript newline-separator style and the comma-separator style - New 'allButLast' mode adds commas to all items except the last, matching the conventional style used in most languages - Formatter processes collections innermost-first to keep indices stable across splices, and collects all item positions before deciding so the last item can be identified for 'allButLast' Co-Authored-By: Claude Sonnet 4.6 --- src/Formatter.spec.ts | 174 ++++++++++++++++++++++- src/FormattingOptions.ts | 11 +- src/formatters/TrailingCommaFormatter.ts | 137 +++++++++++++----- 3 files changed, 277 insertions(+), 45 deletions(-) diff --git a/src/Formatter.spec.ts b/src/Formatter.spec.ts index fc71da3..84117fe 100644 --- a/src/Formatter.spec.ts +++ b/src/Formatter.spec.ts @@ -1900,7 +1900,7 @@ end sub`; `, { trailingComma: 'always' }); }); - it('removes trailing comma from last item in a multi-line array', () => { + it('removes all commas from items in a multi-line array', () => { formatEqualTrim(` x = [ 1, @@ -1909,14 +1909,14 @@ end sub`; ] `, ` x = [ - 1, - 2, + 1 + 2 3 ] `, { trailingComma: 'never' }); }); - it('removes trailing comma from last item in a multi-line AA', () => { + it('removes all commas from items in a multi-line AA', () => { formatEqualTrim(` x = { a: 1, @@ -1924,7 +1924,7 @@ end sub`; } `, ` x = { - a: 1, + a: 1 b: 2 } `, { trailingComma: 'never' }); @@ -1955,6 +1955,170 @@ end sub`; 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', () => { diff --git a/src/FormattingOptions.ts b/src/FormattingOptions.ts index 40aeabf..2f59a54 100644 --- a/src/FormattingOptions.ts +++ b/src/FormattingOptions.ts @@ -116,13 +116,14 @@ export interface FormattingOptions { */ maxConsecutiveEmptyLines?: number; /** - * Controls trailing commas on the last item of multi-line arrays and associative arrays. - * - `'always'`: ensure a trailing comma is present - * - `'never'`: remove any trailing comma - * - `'original'` or omitted: leave trailing commas unchanged + * 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' | 'never' | 'original'; + 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. diff --git a/src/formatters/TrailingCommaFormatter.ts b/src/formatters/TrailingCommaFormatter.ts index 7cd18e9..57d9f23 100644 --- a/src/formatters/TrailingCommaFormatter.ts +++ b/src/formatters/TrailingCommaFormatter.ts @@ -11,68 +11,135 @@ export class TrailingCommaFormatter { return tokens; } - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]; - let openKind: TokenKind | undefined; - let closeKind: TokenKind | undefined; - + // 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) { - openKind = TokenKind.LeftCurlyBrace; - closeKind = TokenKind.RightCurlyBrace; + openTokens.push({ token: token, openKind: TokenKind.LeftCurlyBrace, closeKind: TokenKind.RightCurlyBrace }); } else if (token.kind === TokenKind.LeftSquareBracket) { - openKind = TokenKind.LeftSquareBracket; - closeKind = TokenKind.RightSquareBracket; - } else { - continue; + 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]; - // Only process multi-line arrays/AAs (ones that contain a Newline between brackets) - const closingToken = util.getClosingToken(tokens, i, openKind, closeKind); + // 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); - const isMultiLine = tokens.slice(i, closeIndex).some(t => t.kind === TokenKind.Newline); + + // Only process multiline collections + const isMultiLine = tokens.slice(openIndex, closeIndex).some(t => t.kind === TokenKind.Newline); if (!isMultiLine) { continue; } - // Find the last non-whitespace, non-newline token before the closing bracket - const lastItemToken = this.getPreviousContentToken(tokens, closeIndex); - if (!lastItemToken) { - 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 + }); } - const lastItemIndex = tokens.indexOf(lastItemToken); - // Skip empty collections - if (lastItemToken.kind === openKind) { - continue; + // 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' }); + } } - if (mode === 'always') { - if (lastItemToken.kind !== TokenKind.Comma) { - // Insert a comma after the last item token - tokens.splice(lastItemIndex + 1, 0, { + // 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 if (mode === 'never') { - if (lastItemToken.kind === TokenKind.Comma) { - tokens.splice(lastItemIndex, 1); + } else { + tokens.splice(mod.index, 1); } } } + return tokens; } - /** Like getPreviousNonWhitespaceToken but also skips Newline tokens */ - private getPreviousContentToken(tokens: Token[], startIndex: number): Token | undefined { - for (let i = startIndex - 1; i >= 0; i--) { + /** + * 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 && t.kind !== TokenKind.Newline) { - return t; + if (t.kind === TokenKind.Whitespace) { + continue; + } + if (t.kind === TokenKind.Newline) { + return undefined; // blank line } + return i; } + return undefined; } } From d37204fa3ece21441ef03dd81fd32f9176f89bb1 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Sat, 28 Mar 2026 20:07:32 -0300 Subject: [PATCH 3/7] docs: document all missing bsfmt.json options in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added entries for insertSpaceAfterConditionalCompileSymbol, trailingComma, maxConsecutiveEmptyLines, blankLinesBetweenFunctions, singleLineIf, inlineArrayAndObjectThreshold, removeBlankLinesAtStartOfBlock, and alignAssignments — all of which existed in FormattingOptions but were absent from the options table. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 6c03954..e7e106d 100644 --- a/README.md +++ b/README.md @@ -87,10 +87,18 @@ 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.`| +|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 From 1a93326f8d879d88b456967bb884fc6eec4d151d Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Sat, 28 Mar 2026 21:27:55 -0300 Subject: [PATCH 4/7] more tests --- src/Formatter.spec.ts | 72 +++++++++++ .../AlignAssignmentsFormatter.spec.ts | 27 +++++ ...lankLinesBetweenFunctionsFormatter.spec.ts | 74 ++++++++++++ .../InlineArrayAndObjectFormatter.spec.ts | 52 ++++++++ ...eBlankLinesAtStartOfBlockFormatter.spec.ts | 22 ++++ src/formatters/SingleLineIfFormatter.spec.ts | 113 ++++++++++++++++++ src/formatters/TrailingCommaFormatter.spec.ts | 59 +++++++++ 7 files changed, 419 insertions(+) create mode 100644 src/formatters/AlignAssignmentsFormatter.spec.ts create mode 100644 src/formatters/BlankLinesBetweenFunctionsFormatter.spec.ts create mode 100644 src/formatters/InlineArrayAndObjectFormatter.spec.ts create mode 100644 src/formatters/RemoveBlankLinesAtStartOfBlockFormatter.spec.ts create mode 100644 src/formatters/SingleLineIfFormatter.spec.ts create mode 100644 src/formatters/TrailingCommaFormatter.spec.ts diff --git a/src/Formatter.spec.ts b/src/Formatter.spec.ts index 84117fe..90746c1 100644 --- a/src/Formatter.spec.ts +++ b/src/Formatter.spec.ts @@ -2216,6 +2216,38 @@ end sub`; 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', () => { @@ -2283,6 +2315,30 @@ end sub`; 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', () => { @@ -2355,6 +2411,22 @@ end sub`; ] `); }); + + 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', () => { diff --git a/src/formatters/AlignAssignmentsFormatter.spec.ts b/src/formatters/AlignAssignmentsFormatter.spec.ts new file mode 100644 index 0000000..63eb0b4 --- /dev/null +++ b/src/formatters/AlignAssignmentsFormatter.spec.ts @@ -0,0 +1,27 @@ +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); + }); +}); diff --git a/src/formatters/BlankLinesBetweenFunctionsFormatter.spec.ts b/src/formatters/BlankLinesBetweenFunctionsFormatter.spec.ts new file mode 100644 index 0000000..5adfdcd --- /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/InlineArrayAndObjectFormatter.spec.ts b/src/formatters/InlineArrayAndObjectFormatter.spec.ts new file mode 100644 index 0000000..5514fa3 --- /dev/null +++ b/src/formatters/InlineArrayAndObjectFormatter.spec.ts @@ -0,0 +1,52 @@ +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 there is no closing token for a nested bracket', () => { + // Nested [ with no closing bracket inside a multiline outer [ + // The outer [ IS multiline, hasNestedMultiLine is called, the inner [ has no closer → continue (line 90/91) + const tokens = [ + { kind: TokenKind.LeftSquareBracket, text: '[' }, + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.LeftSquareBracket, text: '[' }, // nested, no closing bracket + { kind: TokenKind.Newline, text: '\n' }, + { kind: TokenKind.RightSquareBracket, text: ']' } // closes the outer [ + ] as any[]; + // Should not throw; since hasNestedMultiLine can't find a closer for the inner [ + // it continues and eventually returns false (no confirmed multi-line nested) + const result = formatter.format(tokens, { inlineArrayAndObjectThreshold: 100 } as any); + expect(result).to.be.an('array'); + }); +}); 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/SingleLineIfFormatter.spec.ts b/src/formatters/SingleLineIfFormatter.spec.ts new file mode 100644 index 0000000..a7013c9 --- /dev/null +++ b/src/formatters/SingleLineIfFormatter.spec.ts @@ -0,0 +1,113 @@ +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); + }); + }); + + 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); + }); + }); + + 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/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; + }); +}); From a6dfc8c590eb14e0203d3f4849b45fafb704eb73 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Sat, 28 Mar 2026 22:16:37 -0300 Subject: [PATCH 5/7] remaining coverage --- .../AlignAssignmentsFormatter.spec.ts | 23 ++++++++++ .../InlineArrayAndObjectFormatter.spec.ts | 30 ++++++++++--- src/formatters/SingleLineIfFormatter.spec.ts | 43 +++++++++++++++++++ src/formatters/SingleLineIfFormatter.ts | 4 +- 4 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/formatters/AlignAssignmentsFormatter.spec.ts b/src/formatters/AlignAssignmentsFormatter.spec.ts index 63eb0b4..5e7059f 100644 --- a/src/formatters/AlignAssignmentsFormatter.spec.ts +++ b/src/formatters/AlignAssignmentsFormatter.spec.ts @@ -24,4 +24,27 @@ describe('AlignAssignmentsFormatter', () => { 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/InlineArrayAndObjectFormatter.spec.ts b/src/formatters/InlineArrayAndObjectFormatter.spec.ts index 5514fa3..d0afe5a 100644 --- a/src/formatters/InlineArrayAndObjectFormatter.spec.ts +++ b/src/formatters/InlineArrayAndObjectFormatter.spec.ts @@ -34,18 +34,34 @@ describe('InlineArrayAndObjectFormatter', () => { expect(result.map((t: any) => t.text).join('')).to.equal('[\n1\n'); }); - it('hasNestedMultiLine continues when there is no closing token for a nested bracket', () => { - // Nested [ with no closing bracket inside a multiline outer [ - // The outer [ IS multiline, hasNestedMultiLine is called, the inner [ has no closer → continue (line 90/91) + 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.LeftSquareBracket, text: '[' }, // nested, no closing bracket + { kind: TokenKind.LeftCurlyBrace, text: '{' }, // no matching } { kind: TokenKind.Newline, text: '\n' }, - { kind: TokenKind.RightSquareBracket, text: ']' } // closes the outer [ + { 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[]; - // Should not throw; since hasNestedMultiLine can't find a closer for the inner [ - // it continues and eventually returns false (no confirmed multi-line nested) const result = formatter.format(tokens, { inlineArrayAndObjectThreshold: 100 } as any); expect(result).to.be.an('array'); }); diff --git a/src/formatters/SingleLineIfFormatter.spec.ts b/src/formatters/SingleLineIfFormatter.spec.ts index a7013c9..b5fc995 100644 --- a/src/formatters/SingleLineIfFormatter.spec.ts +++ b/src/formatters/SingleLineIfFormatter.spec.ts @@ -20,6 +20,26 @@ describe('SingleLineIfFormatter', () => { 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()', () => { @@ -60,6 +80,29 @@ describe('SingleLineIfFormatter', () => { // 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()', () => { diff --git a/src/formatters/SingleLineIfFormatter.ts b/src/formatters/SingleLineIfFormatter.ts index d4ceca9..469aef6 100644 --- a/src/formatters/SingleLineIfFormatter.ts +++ b/src/formatters/SingleLineIfFormatter.ts @@ -145,7 +145,7 @@ export class SingleLineIfFormatter { while (newlineAfterThenIdx < endIfIdx && tokens[newlineAfterThenIdx].kind === TokenKind.Whitespace) { newlineAfterThenIdx++; } - if (tokens[newlineAfterThenIdx]?.kind !== TokenKind.Newline) { + if (tokens[newlineAfterThenIdx].kind !== TokenKind.Newline) { return; // not a multi-line if } @@ -154,7 +154,7 @@ export class SingleLineIfFormatter { while (newlineBeforeEndIfIdx > newlineAfterThenIdx && tokens[newlineBeforeEndIfIdx].kind === TokenKind.Whitespace) { newlineBeforeEndIfIdx--; } - if (tokens[newlineBeforeEndIfIdx]?.kind !== TokenKind.Newline) { + if (tokens[newlineBeforeEndIfIdx].kind !== TokenKind.Newline) { return; // unexpected structure } From b6089f74bc9bd7a6b780b1770a4d5edda8bde205 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Sat, 28 Mar 2026 22:18:56 -0300 Subject: [PATCH 6/7] Apply suggestion from @chrisdp --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e7e106d..52ee67f 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ All boolean, string, and integer [`bsfmt.json`](#bsfmtjson-options) options are |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.`| +|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: From e3ba62dcd4dc88fecf998846b53e7140a6ccb8e1 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Sat, 28 Mar 2026 22:21:03 -0300 Subject: [PATCH 7/7] linting fix --- src/formatters/BlankLinesBetweenFunctionsFormatter.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/formatters/BlankLinesBetweenFunctionsFormatter.spec.ts b/src/formatters/BlankLinesBetweenFunctionsFormatter.spec.ts index 5adfdcd..28f6f3b 100644 --- a/src/formatters/BlankLinesBetweenFunctionsFormatter.spec.ts +++ b/src/formatters/BlankLinesBetweenFunctionsFormatter.spec.ts @@ -14,7 +14,7 @@ describe('BlankLinesBetweenFunctionsFormatter', () => { const tokens = [ { kind: TokenKind.EndFunction, text: 'end function' }, { kind: TokenKind.Whitespace, text: ' ' }, - { kind: TokenKind.Comment, text: "' comment" }, + { kind: TokenKind.Comment, text: '\' comment' }, { kind: TokenKind.Newline, text: '\n' }, { kind: TokenKind.Function, text: 'function' }, { kind: TokenKind.Whitespace, text: ' ' },