diff --git a/.gitignore b/.gitignore index b4865f8..63ba673 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules /.claude/settings.local.json +tmpclaude* \ No newline at end of file diff --git a/dist/cli.js b/dist/cli.js index a7f848a..bf272df 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -30,7 +30,7 @@ function _interopNamespaceDefault(e) { const fs__namespace = /* @__PURE__ */ _interopNamespaceDefault(fs); const glob__namespace = /* @__PURE__ */ _interopNamespaceDefault(glob); const path__namespace = /* @__PURE__ */ _interopNamespaceDefault(path); -async function formatFiles(targetDir, config, dryRun) { +async function formatDirectory(targetDir, config, dryRun) { const container = new Container.Container(); ServiceRegistration.ServiceRegistration.registerServices(container, config); const include = config.sorting?.include || ["**/*.{ts,tsx,js,jsx}"]; @@ -68,54 +68,102 @@ async function formatFiles(targetDir, config, dryRun) { console.info(`Formatted ${formattedCount} of ${files.length} files.`); } } +async function formatSingleFile(filePath, config, dryRun) { + const container = new Container.Container(); + ServiceRegistration.ServiceRegistration.registerServices(container, config); + const pipeline = container.resolve("FormatterPipeline"); + try { + const context = await pipeline.formatFile(filePath, dryRun); + if (context.changed) { + if (dryRun) { + console.info(`Would format: ${filePath}`); + } else { + console.log(`📊 Formatted: ${filePath}`); + } + } else { + console.info(`No changes needed: ${filePath}`); + } + } catch (error) { + console.error(`Error formatting file ${filePath}:`, error.message); + } +} +function isSupportedFile(filePath) { + const supportedExtensions = [".ts", ".tsx", ".js", ".jsx"]; + return supportedExtensions.some((ext) => filePath.endsWith(ext)); +} async function main() { const args = process.argv.slice(2); - let targetDir = process.cwd(); + let target = process.cwd(); let dryRun = false; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "--dry") { dryRun = true; } else if (!arg.startsWith("-")) { - targetDir = path__namespace.resolve(arg); + target = path__namespace.resolve(arg); } else { console.error(`Error: Unsupported option "${arg}". Only --dry is supported.`); process.exit(1); } } try { - const config = ConfigLoader.ConfigLoader.loadConfig(targetDir); - if (ConfigLoader.ConfigLoader.hasConfigFile(targetDir)) { + const targetStat = fs__namespace.existsSync(target) ? fs__namespace.statSync(target) : null; + const isFile = targetStat?.isFile() ?? false; + const isDirectory = targetStat?.isDirectory() ?? false; + if (!targetStat) { + console.error(`Error: Target "${target}" does not exist.`); + process.exit(1); + } + const configDir = isFile ? path__namespace.dirname(target) : target; + const config = ConfigLoader.ConfigLoader.loadConfig(configDir); + if (ConfigLoader.ConfigLoader.hasConfigFile(configDir)) { console.log("Using custom configuration from tsfmt.config.ts"); } - if (config.packageJson?.enabled) { - const packagePath = path__namespace.join(targetDir, "package.json"); - if (fs__namespace.existsSync(packagePath)) { - console.log(`📦 Processing ${packagePath}...`); - sortPackage.sortPackageFile(packagePath, { - customSortOrder: config.packageJson.customSortOrder, - indentation: config.packageJson.indentation, - dryRun - }); + if (isFile) { + if (!isSupportedFile(target)) { + console.error(`Error: Unsupported file type. Supported: .ts, .tsx, .js, .jsx`); + process.exit(1); } - } - if (config.tsConfig?.enabled) { - const tsconfigPath = path__namespace.join(targetDir, "tsconfig.json"); - if (fs__namespace.existsSync(tsconfigPath)) { - console.log(`🔧 Processing ${tsconfigPath}...`); - sortTSConfig.sortTsConfigFile(tsconfigPath, { - indentation: config.tsConfig.indentation, - dryRun - }); + if (config.codeStyle?.enabled || config.imports?.enabled || config.sorting?.enabled || config.spacing?.enabled) { + await formatSingleFile(target, config, dryRun); } + if (dryRun) { + console.info("Dry run completed. No files were modified."); + } else { + console.info("Formatting completed successfully."); + } + return; } - if (config.codeStyle?.enabled || config.imports?.enabled || config.sorting?.enabled || config.spacing?.enabled) { - await formatFiles(targetDir, config, dryRun); - } - if (dryRun) { - console.info("Dry run completed. No files were modified."); - } else { - console.info("Formatting completed successfully."); + if (isDirectory) { + if (config.packageJson?.enabled) { + const packagePath = path__namespace.join(target, "package.json"); + if (fs__namespace.existsSync(packagePath)) { + console.log(`📦 Processing ${packagePath}...`); + sortPackage.sortPackageFile(packagePath, { + customSortOrder: config.packageJson.customSortOrder, + indentation: config.packageJson.indentation, + dryRun + }); + } + } + if (config.tsConfig?.enabled) { + const tsconfigPath = path__namespace.join(target, "tsconfig.json"); + if (fs__namespace.existsSync(tsconfigPath)) { + console.log(`🔧 Processing ${tsconfigPath}...`); + sortTSConfig.sortTsConfigFile(tsconfigPath, { + indentation: config.tsConfig.indentation, + dryRun + }); + } + } + if (config.codeStyle?.enabled || config.imports?.enabled || config.sorting?.enabled || config.spacing?.enabled) { + await formatDirectory(target, config, dryRun); + } + if (dryRun) { + console.info("Dry run completed. No files were modified."); + } else { + console.info("Formatting completed successfully."); + } } } catch (error) { console.error("Error during formatting:", error.message); diff --git a/dist/core/config/ConfigLoader.js b/dist/core/config/ConfigLoader.js index 8fde041..14704df 100644 --- a/dist/core/config/ConfigLoader.js +++ b/dist/core/config/ConfigLoader.js @@ -79,7 +79,7 @@ const config: CoreConfig = { semicolons: "always", indentWidth: 4, lineWidth: 120, - }, + }, // Import organization imports: { @@ -106,7 +106,7 @@ const config: CoreConfig = { // JSON file sorting packageJson: { enabled: true }, tsConfig: { enabled: true }, -}; + }; export default config; `; diff --git a/dist/core/di/ServiceRegistration.js b/dist/core/di/ServiceRegistration.js index 2939229..a97ca3a 100644 --- a/dist/core/di/ServiceRegistration.js +++ b/dist/core/di/ServiceRegistration.js @@ -13,6 +13,7 @@ const DocBlockCommentRule = require("../formatters/rules/style/DocBlockCommentRu const IndentationRule = require("../formatters/rules/style/IndentationRule.js"); const QuoteStyleRule = require("../formatters/rules/style/QuoteStyleRule.js"); const SemicolonRule = require("../formatters/rules/style/SemicolonRule.js"); +const StructuralIndentationRule = require("../formatters/rules/style/StructuralIndentationRule.js"); const FormatterPipeline = require("../pipeline/FormatterPipeline.js"); class ServiceRegistration { static registerServices(container, config) { @@ -21,6 +22,7 @@ class ServiceRegistration { container.singleton(SemicolonRule.SemicolonRule); container.singleton(BracketSpacingRule.BracketSpacingRule); container.singleton(IndentationRule.IndentationRule); + container.singleton(StructuralIndentationRule.StructuralIndentationRule); container.singleton(BlockSpacingRule.BlockSpacingRule); container.singleton(DocBlockCommentRule.DocBlockCommentRule); container.singleton(ImportOrganizationRule.ImportOrganizationRule); diff --git a/dist/core/formatters/rules/style/StructuralIndentationRule.js b/dist/core/formatters/rules/style/StructuralIndentationRule.js new file mode 100644 index 0000000..81d84c3 --- /dev/null +++ b/dist/core/formatters/rules/style/StructuralIndentationRule.js @@ -0,0 +1,250 @@ +"use strict"; +Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); +const BaseFormattingRule = require("../../BaseFormattingRule.js"); +class StructuralIndentationRule extends BaseFormattingRule.BaseFormattingRule { + constructor() { + super(...arguments); + this.name = "StructuralIndentationRule"; + } + skipString(source, start, quote) { + let i = start + 1; + let newlines = 0; + const isTemplate = quote === "`"; + while (i < source.length) { + const char = source[i]; + if (char === "\n") { + newlines++; + i++; + continue; + } + if (char === "\\") { + i += 2; + continue; + } + if (isTemplate && char === "$" && source[i + 1] === "{") { + i += 2; + let braceCount = 1; + while (i < source.length && braceCount > 0) { + if (source[i] === "\n") { + newlines++; + } else if (source[i] === "{") { + braceCount++; + } else if (source[i] === "}") { + braceCount--; + } else if (source[i] === '"' || source[i] === "'" || source[i] === "`") { + const result = this.skipString(source, i, source[i]); + i = result.pos; + newlines += result.newlines; + continue; + } + i++; + } + continue; + } + if (char === quote) { + return { pos: i + 1, newlines }; + } + i++; + } + return { pos: i, newlines }; + } + isRegexStart(source, index) { + let i = index - 1; + while (i >= 0 && (source[i] === " " || source[i] === " ")) { + i--; + } + if (i < 0) return true; + const char = source[i]; + const regexPreceders = ["(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "\n", "return", "case"]; + if (regexPreceders.includes(char)) { + return true; + } + const keywords = ["return", "case", "typeof", "void", "delete", "throw", "in", "instanceof"]; + for (const kw of keywords) { + if (index >= kw.length && source.substring(index - kw.length, index).endsWith(kw)) { + return true; + } + } + return false; + } + skipRegex(source, start) { + let i = start + 1; + let inCharClass = false; + while (i < source.length) { + const char = source[i]; + if (char === "\\") { + i += 2; + continue; + } + if (char === "[") { + inCharClass = true; + } else if (char === "]") { + inCharClass = false; + } else if (char === "/" && !inCharClass) { + i++; + while (i < source.length && /[gimsuy]/.test(source[i])) { + i++; + } + return i; + } else if (char === "\n") { + return i; + } + i++; + } + return i; + } + getLineIndentLevel(line, indentWidth) { + const leadingWhitespace = line.match(/^[\t ]*/)?.[0] || ""; + const tabCount = (leadingWhitespace.match(/\t/g) || []).length; + const spaceCount = (leadingWhitespace.match(/ /g) || []).length; + return tabCount + Math.floor(spaceCount / indentWidth); + } + startsWithClosingBracket(trimmedLine) { + return /^[}\])]/.test(trimmedLine); + } + findBracketFixes(source, lines, indentWidth) { + const fixes = []; + const stack = []; + const lineIndentCorrections = /* @__PURE__ */ new Map(); + let i = 0; + let line = 0; + let column = 0; + let lineStart = 0; + const openBrackets = { + "{": "}", + "[": "]", + "(": ")" + }; + const closeBrackets = { + "}": "{", + "]": "[", + ")": "(" + }; + while (i < source.length) { + const char = source[i]; + if (char === "\n") { + line++; + column = 0; + lineStart = i + 1; + i++; + continue; + } + if (char === '"' || char === "'" || char === "`") { + const result = this.skipString(source, i, char); + i = result.pos; + line += result.newlines; + if (result.newlines > 0) { + let lastNewline = i - 1; + while (lastNewline >= 0 && source[lastNewline] !== "\n") { + lastNewline--; + } + lineStart = lastNewline + 1; + } + column = i - lineStart; + continue; + } + if (char === "/" && source[i + 1] === "/") { + while (i < source.length && source[i] !== "\n") { + i++; + } + continue; + } + if (char === "/" && source[i + 1] === "*") { + i += 2; + while (i < source.length - 1 && !(source[i] === "*" && source[i + 1] === "/")) { + if (source[i] === "\n") { + line++; + lineStart = i + 1; + } + i++; + } + i += 2; + column = i - lineStart; + continue; + } + if (char === "/" && this.isRegexStart(source, i)) { + i = this.skipRegex(source, i); + column = i - lineStart; + continue; + } + if (openBrackets[char]) { + const lineIndent = lineIndentCorrections.has(line) ? lineIndentCorrections.get(line) : this.getLineIndentLevel(lines[line], indentWidth); + stack.push({ + char, + position: i, + line, + lineIndent + }); + } + if (closeBrackets[char]) { + const expectedOpen = closeBrackets[char]; + let matchIndex = -1; + for (let j = stack.length - 1; j >= 0; j--) { + if (stack[j].char === expectedOpen) { + matchIndex = j; + break; + } + } + if (matchIndex !== -1) { + const openBracket = stack[matchIndex]; + stack.splice(matchIndex, 1); + if (line !== openBracket.line) { + const currentIndent = this.getLineIndentLevel(lines[line], indentWidth); + const trimmedLine = lines[line].trimStart(); + if (this.startsWithClosingBracket(trimmedLine)) { + if (currentIndent !== openBracket.lineIndent) { + fixes.push({ + position: i, + line, + column, + targetIndent: openBracket.lineIndent + }); + if (!lineIndentCorrections.has(line)) { + lineIndentCorrections.set(line, openBracket.lineIndent); + } + } + } + } + } + } + i++; + column++; + } + return fixes; + } + apply(source, filePath) { + const config = this.getCodeStyleConfig(); + if (!config?.indentStyle || !config.indentWidth) { + return source; + } + const indentWidth = config.indentWidth; + const indentUnit = config.indentStyle === "tab" ? " " : " ".repeat(indentWidth); + const lines = source.split("\n"); + const fixes = this.findBracketFixes(source, lines, indentWidth); + if (fixes.length === 0) { + return source; + } + const fixesByLine = /* @__PURE__ */ new Map(); + for (const fix of fixes) { + if (!fixesByLine.has(fix.line)) { + fixesByLine.set(fix.line, []); + } + fixesByLine.get(fix.line).push(fix); + } + const result = []; + for (let i = 0; i < lines.length; i++) { + const lineFixes = fixesByLine.get(i); + if (lineFixes && lineFixes.length > 0) { + lineFixes.sort((a, b) => a.column - b.column); + const primaryFix = lineFixes[0]; + const trimmedLine = lines[i].trimStart(); + const newIndent = indentUnit.repeat(primaryFix.targetIndent); + result.push(newIndent + trimmedLine); + } else { + result.push(lines[i]); + } + } + return result.join("\n"); + } +} +exports.StructuralIndentationRule = StructuralIndentationRule; diff --git a/dist/core/pipeline/FormatterPipeline.js b/dist/core/pipeline/FormatterPipeline.js index b137b13..0aade64 100644 --- a/dist/core/pipeline/FormatterPipeline.js +++ b/dist/core/pipeline/FormatterPipeline.js @@ -114,6 +114,11 @@ class FormatterPipeline { } return files; } + /** Check if source code contains a tsfmt-ignore directive */ + shouldIgnoreFile(source) { + const header = source.slice(0, 1e3); + return /(?:\/\/|\/\*|\*)\s*tsfmt-ignore|^\s*tsfmt-ignore\s*$/m.test(header); + } /** * Format a file using the configured formatters in sequence * @param filePath - Absolute path to the file to format @@ -123,6 +128,16 @@ class FormatterPipeline { */ async formatFile(filePath, dryRun = false) { const originalSource = await fs__namespace.readFile(filePath, "utf-8"); + if (this.shouldIgnoreFile(originalSource)) { + return { + filePath, + originalSource, + currentSource: originalSource, + executions: [], + changed: false, + dryRun + }; + } const context = { filePath, originalSource, @@ -211,6 +226,7 @@ class FormatterPipeline { this.addRuleByName("SemicolonRule", ConfigTypes.FormatterOrder.CodeStyle); this.addRuleByName("BracketSpacingRule", ConfigTypes.FormatterOrder.CodeStyle); this.addRuleByName("IndentationRule", ConfigTypes.FormatterOrder.CodeStyle); + this.addRuleByName("StructuralIndentationRule", ConfigTypes.FormatterOrder.CodeStyle); this.addRuleByName("BlockSpacingRule", ConfigTypes.FormatterOrder.CodeStyle); this.addRuleByName("DocBlockCommentRule", ConfigTypes.FormatterOrder.CodeStyle); } diff --git a/dist/index.js b/dist/index.js index 17a5915..4970b8c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -25,6 +25,7 @@ const DocBlockCommentRule = require("./core/formatters/rules/style/DocBlockComme const IndentationRule = require("./core/formatters/rules/style/IndentationRule.js"); const QuoteStyleRule = require("./core/formatters/rules/style/QuoteStyleRule.js"); const SemicolonRule = require("./core/formatters/rules/style/SemicolonRule.js"); +const StructuralIndentationRule = require("./core/formatters/rules/style/StructuralIndentationRule.js"); const FormatterPipeline = require("./core/pipeline/FormatterPipeline.js"); const _package = require("./formatters/package.js"); const types = require("./shared/types.js"); @@ -58,6 +59,7 @@ exports.DocBlockCommentRule = DocBlockCommentRule.DocBlockCommentRule; exports.IndentationRule = IndentationRule.IndentationRule; exports.QuoteStyleRule = QuoteStyleRule.QuoteStyleRule; exports.SemicolonRule = SemicolonRule.SemicolonRule; +exports.StructuralIndentationRule = StructuralIndentationRule.StructuralIndentationRule; exports.FormatterError = FormatterPipeline.FormatterError; exports.FormatterPipeline = FormatterPipeline.FormatterPipeline; exports.sortExportsKeys = _package.sortExportsKeys; diff --git a/src/build-plugins/transformGenericsPlugin.ts b/src/build-plugins/transformGenericsPlugin.ts index e44bafd..347e954 100644 --- a/src/build-plugins/transformGenericsPlugin.ts +++ b/src/build-plugins/transformGenericsPlugin.ts @@ -116,5 +116,5 @@ export function transformGenericsPlugin() { } } } -}; + }; } \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts index ce71aac..ac98aef 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,14 +4,13 @@ import * as path from "path"; import "reflect-metadata"; import { FormatterPipeline } from "./core"; import { CoreConfig, ConfigLoader } from "./core"; -import { Container, ServiceRegistration } from "./core/di"; +import { Container, ServiceRegistration } from "./core"; import { sortPackageFile } from "./sortPackage"; import { sortTsConfigFile } from "./sortTSConfig"; -/** Format files using the FormatterPipeline */ -async function formatFiles(targetDir: string, config: CoreConfig, dryRun: boolean): Promise { - // Set up dependency injection container +/** Format files in a directory using the FormatterPipeline */ +async function formatDirectory(targetDir: string, config: CoreConfig, dryRun: boolean): Promise { const container = new Container(); ServiceRegistration.registerServices(container, config); // Get include/exclude patterns @@ -25,7 +24,7 @@ async function formatFiles(targetDir: string, config: CoreConfig, dryRun: boolea cwd: targetDir, ignore: finalExclude, absolute: true, -})); + })); if (files.length === 0) { console.info("No files found to format."); @@ -62,12 +61,40 @@ async function formatFiles(targetDir: string, config: CoreConfig, dryRun: boolea } } +/** Format a single file using the FormatterPipeline */ +async function formatSingleFile(filePath: string, config: CoreConfig, dryRun: boolean): Promise { + const container = new Container(); + ServiceRegistration.registerServices(container, config); + const pipeline = container.resolve("FormatterPipeline"); + + try { + const context = await pipeline.formatFile(filePath, dryRun); + + if (context.changed) { + if (dryRun) { + console.info(`Would format: ${filePath}`); + } else { + console.log(`📊 Formatted: ${filePath}`); + } + } else { + console.info(`No changes needed: ${filePath}`); + } + } catch (error) { + console.error(`Error formatting file ${filePath}:`, (error as Error).message); + } +} + +/** Check if a path is a supported file type */ +function isSupportedFile(filePath: string): boolean { + const supportedExtensions = [".ts", ".tsx", ".js", ".jsx"]; + return supportedExtensions.some(ext => filePath.endsWith(ext)); +} + /** Main CLI function */ async function main(): Promise { const args = process.argv.slice(2); // Parse command line arguments - - let targetDir = process.cwd(); + let target = process.cwd(); let dryRun = false; for (let i = 0; i < args.length; i++) { @@ -76,7 +103,7 @@ async function main(): Promise { if (arg === "--dry") { dryRun = true; } else if (!arg.startsWith("-")) { - targetDir = path.resolve(arg); + target = path.resolve(arg); } else { console.error(`Error: Unsupported option "${arg}". Only --dry is supported.`); process.exit(1); @@ -84,56 +111,89 @@ async function main(): Promise { } try { - // Load configuration + // Determine if target is a file or directory + const targetStat = fs.existsSync(target) ? fs.statSync(target) : null; + const isFile = targetStat?.isFile() ?? false; + const isDirectory = targetStat?.isDirectory() ?? false; - const config = ConfigLoader.loadConfig(targetDir); - // Log if custom config is being used + if (!targetStat) { + console.error(`Error: Target "${target}" does not exist.`); + process.exit(1); + } + + // For files, load config from the file's directory; for directories, use the target + const configDir = isFile ? path.dirname(target) : target; + const config = ConfigLoader.loadConfig(configDir); - if (ConfigLoader.hasConfigFile(targetDir)) { + // Log if custom config is being used + if (ConfigLoader.hasConfigFile(configDir)) { console.log("Using custom configuration from tsfmt.config.ts"); } - // Sort package.json - - if (config.packageJson?.enabled) { - const packagePath = path.join(targetDir, "package.json"); - - if (fs.existsSync(packagePath)) { - console.log(`📦 Processing ${packagePath}...`); - sortPackageFile(packagePath, { - customSortOrder: config.packageJson.customSortOrder, - indentation: config.packageJson.indentation, - dryRun, -}); + + // Handle single file formatting + if (isFile) { + if (!isSupportedFile(target)) { + console.error(`Error: Unsupported file type. Supported: .ts, .tsx, .js, .jsx`); + process.exit(1); } - } - // Sort tsconfig.json - if (config.tsConfig?.enabled) { - const tsconfigPath = path.join(targetDir, "tsconfig.json"); + if (config.codeStyle?.enabled || + config.imports?.enabled || + config.sorting?.enabled || + config.spacing?.enabled) { + await formatSingleFile(target, config, dryRun); + } - if (fs.existsSync(tsconfigPath)) { - console.log(`🔧 Processing ${tsconfigPath}...`); - sortTsConfigFile(tsconfigPath, { - indentation: config.tsConfig.indentation, - dryRun, -}); + if (dryRun) { + console.info("Dry run completed. No files were modified."); + } else { + console.info("Formatting completed successfully."); } + return; } - // Format files using the new pipeline - // Check if any formatters are enabled - if (config.codeStyle?.enabled || + // Handle directory formatting + if (isDirectory) { + // Sort package.json + if (config.packageJson?.enabled) { + const packagePath = path.join(target, "package.json"); + + if (fs.existsSync(packagePath)) { + console.log(`📦 Processing ${packagePath}...`); + sortPackageFile(packagePath, { + customSortOrder: config.packageJson.customSortOrder, + indentation: config.packageJson.indentation, + dryRun, + }); + } + } - config.imports?.enabled || - config.sorting?.enabled || - config.spacing?.enabled) { - await formatFiles(targetDir, config, dryRun); - } + // Sort tsconfig.json + if (config.tsConfig?.enabled) { + const tsconfigPath = path.join(target, "tsconfig.json"); - if (dryRun) { - console.info("Dry run completed. No files were modified."); - } else { - console.info("Formatting completed successfully."); + if (fs.existsSync(tsconfigPath)) { + console.log(`🔧 Processing ${tsconfigPath}...`); + sortTsConfigFile(tsconfigPath, { + indentation: config.tsConfig.indentation, + dryRun, + }); + } + } + + // Format files using the pipeline + if (config.codeStyle?.enabled || + config.imports?.enabled || + config.sorting?.enabled || + config.spacing?.enabled) { + await formatDirectory(target, config, dryRun); + } + + if (dryRun) { + console.info("Dry run completed. No files were modified."); + } else { + console.info("Formatting completed successfully."); + } } } catch (error) { console.error("Error during formatting:", (error as Error).message); diff --git a/src/core/ast/ASTAnalyzer.ts b/src/core/ast/ASTAnalyzer.ts index e4503eb..fffbdb5 100644 --- a/src/core/ast/ASTAnalyzer.ts +++ b/src/core/ast/ASTAnalyzer.ts @@ -115,7 +115,7 @@ export class ASTAnalyzer { ts.isImportEqualsDeclaration(declaration) || ts.isExportDeclaration(declaration)) { return new Set(); - } + } const refs = this.extractReferences(declaration, name => availableDeclarations.has(name)); // For file declarations, we care about all identifiers @@ -142,7 +142,7 @@ export class ASTAnalyzer { if (ts.isStringLiteral(member.name)) { return member.name.text; } - } + } return ""; } @@ -156,7 +156,7 @@ export class ASTAnalyzer { ts.isFunctionDeclaration(declaration) || ts.isClassDeclaration(declaration)) { return declaration.name?.text || ""; - } + } if (ts.isVariableStatement(declaration)) { const firstDecl = declaration.declarationList.declarations[0]; diff --git a/src/core/ast/ASTTransformer.ts b/src/core/ast/ASTTransformer.ts index 63d53ff..da8907e 100644 --- a/src/core/ast/ASTTransformer.ts +++ b/src/core/ast/ASTTransformer.ts @@ -24,7 +24,7 @@ export class ASTTransformer { const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed, removeComments, -}); + }); return printer.printNode(ts.EmitHint.Unspecified, node, sourceFile); } @@ -53,7 +53,7 @@ export class ASTTransformer { const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed, removeComments: false, -}); + }); // Print the file content diff --git a/src/core/ast/DependencyResolver.ts b/src/core/ast/DependencyResolver.ts index 6860f6d..fd8a2a5 100644 --- a/src/core/ast/DependencyResolver.ts +++ b/src/core/ast/DependencyResolver.ts @@ -99,7 +99,7 @@ export class DependencyResolver { dependencies: getDependencies(item), originalIndex: index, sortedIndex: index, -}); + }); }); // Filter dependencies to only include symbols in our scope diff --git a/src/core/config/ConfigDefaults.ts b/src/core/config/ConfigDefaults.ts index f23d0f6..aaaa2d0 100644 --- a/src/core/config/ConfigDefaults.ts +++ b/src/core/config/ConfigDefaults.ts @@ -32,7 +32,7 @@ export class ConfigDefaults { lineWidth: 120, trailingCommas: "all" as const, arrowParens: "avoid" as const, -}; + }; } /** Get default directories for index generation */ @@ -49,9 +49,9 @@ export class ConfigDefaults { fileExtension: ".ts", indexFileName: "index.ts", recursive: true -}, + }, updateMainIndex: true, -}; + }; } /** Get default import configuration */ @@ -64,7 +64,7 @@ export class ConfigDefaults { groupImports: true, groupOrder: ConfigTypes.getImportGroupOptions(), separateGroups: false, -}; + }; } /** Get default include patterns for TypeScript files */ @@ -86,21 +86,21 @@ export class ConfigDefaults { order: DEFAULT_CLASS_ORDER, groupByVisibility: false, respectDependencies: true, -}, + }, reactComponents: { enabled: true, order: DEFAULT_CLASS_ORDER, groupByVisibility: false, respectDependencies: true, -}, + }, fileDeclarations: { enabled: true, order: DEFAULT_FILE_ORDER, respectDependencies: true, -}, + }, include: this.getDefaultIncludePatterns(), exclude: this.getDefaultExcludePatterns(), -}; + }; } /** Get default spacing configuration */ @@ -110,7 +110,7 @@ export class ConfigDefaults { betweenDeclarations: true, beforeReturns: true, betweenStatementTypes: true, -}; + }; } /** Get default package.json configuration */ @@ -119,7 +119,7 @@ export class ConfigDefaults { enabled: true, customSortOrder: DefaultSortOptions.customSortOrder, indentation: 4, -}; + }; } /** Get default tsconfig.json configuration */ @@ -127,7 +127,7 @@ export class ConfigDefaults { return { enabled: true, indentation: 4, -}; + }; } /** Get default formatter order */ @@ -152,7 +152,7 @@ export class ConfigDefaults { packageJson: this.getDefaultPackageJsonConfig(), tsConfig: this.getDefaultTsConfigConfig(), formatterOrder: this.getDefaultFormatterOrder(), -}; + }; } /** Get default include patterns for JavaScript files */ @@ -170,7 +170,7 @@ export class ConfigDefaults { spacing: {enabled: false}, packageJson: {enabled: false}, tsConfig: {enabled: false}, -}; + }; } /** Create a minimal configuration with only enabled features */ @@ -183,6 +183,6 @@ export class ConfigDefaults { spacing: {enabled: false}, packageJson: {enabled: true}, tsConfig: {enabled: true}, -}; + }; } } \ No newline at end of file diff --git a/src/core/config/ConfigLoader.ts b/src/core/config/ConfigLoader.ts index eca063c..67929e3 100644 --- a/src/core/config/ConfigLoader.ts +++ b/src/core/config/ConfigLoader.ts @@ -71,7 +71,7 @@ const config: CoreConfig = { semicolons: "always", indentWidth: 4, lineWidth: 120, - }, + }, // Import organization imports: { @@ -98,7 +98,7 @@ const config: CoreConfig = { // JSON file sorting packageJson: { enabled: true }, tsConfig: { enabled: true }, -}; + }; export default config; `; @@ -114,7 +114,7 @@ export default config; return { size: this.configCache.size, keys: Array.from(this.configCache.keys()) -}; + }; } /** @@ -152,8 +152,8 @@ export default config; target: ts.ScriptTarget.ES2015, esModuleInterop: true, allowSyntheticDefaultImports: true, -}, -}); + }, + }); return result.outputText; } diff --git a/src/core/config/ConfigMerger.ts b/src/core/config/ConfigMerger.ts index a10134f..abf0c87 100644 --- a/src/core/config/ConfigMerger.ts +++ b/src/core/config/ConfigMerger.ts @@ -28,9 +28,9 @@ export class ConfigMerger { result[key] !== null && !Array.isArray(result[key])) { result[key] = this.deepMerge(result[key] as any, source[key] as any); - } else { + } else { result[key] = source[key] as T[Extract]; - } + } } } diff --git a/src/core/config/ConfigValidator.ts b/src/core/config/ConfigValidator.ts index 5c29cf7..34bfc6c 100644 --- a/src/core/config/ConfigValidator.ts +++ b/src/core/config/ConfigValidator.ts @@ -75,7 +75,7 @@ export class ConfigValidator { valid: errors.length === 0, errors, warnings, -}; + }; } /** diff --git a/src/core/config/__tests__/ConfigMerger.test.ts b/src/core/config/__tests__/ConfigMerger.test.ts index 9f38ee2..9110dac 100644 --- a/src/core/config/__tests__/ConfigMerger.test.ts +++ b/src/core/config/__tests__/ConfigMerger.test.ts @@ -13,8 +13,8 @@ describe("ConfigMerger", () => { const userConfig: Partial = { codeStyle: { quoteStyle: "single", -}, -}; + }, + }; const result = ConfigMerger.merge(userConfig); @@ -27,9 +27,9 @@ describe("ConfigMerger", () => { sorting: { classMembers: { groupByVisibility: true, -}, -}, -}; + }, + }, + }; const result = ConfigMerger.merge(userConfig); @@ -41,8 +41,8 @@ describe("ConfigMerger", () => { const userConfig: Partial = { imports: { groupOrder: ["relative", "external"], -}, -}; + }, + }; const result = ConfigMerger.merge(userConfig); @@ -53,8 +53,8 @@ describe("ConfigMerger", () => { const userConfig: Partial = { codeStyle: { quoteStyle: undefined, -}, -}; + }, + }; const result = ConfigMerger.merge(userConfig); @@ -66,20 +66,20 @@ describe("ConfigMerger", () => { const config1: Partial = { codeStyle: { quoteStyle: "single", -}, -}; + }, + }; const config2: Partial = { codeStyle: { semicolons: "never", -}, -}; + }, + }; const config3: Partial = { codeStyle: { quoteStyle: "double", // Override config1 -}, -}; + }, + }; const result = ConfigMerger.mergeMultiple(config1, config2, config3); diff --git a/src/core/config/__tests__/ConfigValidator.test.ts b/src/core/config/__tests__/ConfigValidator.test.ts index fe975ac..5af9868 100644 --- a/src/core/config/__tests__/ConfigValidator.test.ts +++ b/src/core/config/__tests__/ConfigValidator.test.ts @@ -17,8 +17,8 @@ describe("ConfigValidator", () => { semicolons: "always", indentWidth: 4, lineWidth: 120, -}, -}; + }, + }; const result = ConfigValidator.validate(config); @@ -30,8 +30,8 @@ describe("ConfigValidator", () => { codeStyle: { enabled: true, quoteStyle: "triple" as any, -}, -}; + }, + }; const result = ConfigValidator.validate(config); @@ -43,8 +43,8 @@ describe("ConfigValidator", () => { codeStyle: { enabled: true, semicolons: "sometimes" as any, -}, -}; + }, + }; const result = ConfigValidator.validate(config); @@ -56,8 +56,8 @@ describe("ConfigValidator", () => { codeStyle: { enabled: true, indentWidth: 10, -}, -}; + }, + }; const result = ConfigValidator.validate(config); @@ -69,8 +69,8 @@ describe("ConfigValidator", () => { codeStyle: { enabled: true, lineWidth: 250, -}, -}; + }, + }; const result = ConfigValidator.validate(config); @@ -82,8 +82,8 @@ describe("ConfigValidator", () => { imports: { enabled: true, groupOrder: ["external", "invalid", "relative"], -}, -}; + }, + }; const result = ConfigValidator.validate(config); @@ -97,8 +97,8 @@ describe("ConfigValidator", () => { codeStyle: { enabled: true, quoteStyle: "double", -}, -}; + }, + }; expect(() => ConfigValidator.validateOrThrow(config)).not.toThrow(); }); it("should throw for invalid config", () => { @@ -106,8 +106,8 @@ describe("ConfigValidator", () => { codeStyle: { enabled: true, quoteStyle: "invalid" as any, -}, -}; + }, + }; expect(() => ConfigValidator.validateOrThrow(config)).toThrow("Invalid configuration"); }); }); diff --git a/src/core/di/Container.ts b/src/core/di/Container.ts index 346517c..cdd8af9 100644 --- a/src/core/di/Container.ts +++ b/src/core/di/Container.ts @@ -46,7 +46,7 @@ export class Container { if (line.includes("Container.extractGenericTypeName") || line.includes("Container.resolve")) { continue; - } + } // Find the first external call location const match = line.match(/at\s+.*\s+\((.+):(\d+):(\d+)\)/); @@ -88,7 +88,7 @@ export class Container { if (line.includes("Container.extractGenericTypeNameForRegistration") || line.includes("Container.singleton")) { continue; - } + } // Find the first external call location const match = line.match(/at\s+.*\s+\((.+):(\d+):(\d+)\)/); diff --git a/src/core/di/ServiceRegistration.ts b/src/core/di/ServiceRegistration.ts index c7c613f..3527aa7 100644 --- a/src/core/di/ServiceRegistration.ts +++ b/src/core/di/ServiceRegistration.ts @@ -4,7 +4,7 @@ */ import { CoreConfig } from "../config"; -import { QuoteStyleRule, SemicolonRule, BracketSpacingRule, IndentationRule, BlockSpacingRule, DocBlockCommentRule, ImportOrganizationRule, ClassMemberSortingRule, FileDeclarationSortingRule, BlankLineBetweenDeclarationsRule, BlankLineBetweenStatementTypesRule, BlankLineBeforeReturnsRule, IndexGenerationRule } from "../formatters"; +import { QuoteStyleRule, SemicolonRule, BracketSpacingRule, IndentationRule, StructuralIndentationRule, BlockSpacingRule, DocBlockCommentRule, ImportOrganizationRule, ClassMemberSortingRule, FileDeclarationSortingRule, BlankLineBetweenDeclarationsRule, BlankLineBetweenStatementTypesRule, BlankLineBeforeReturnsRule, IndexGenerationRule } from "../formatters"; import { FormatterPipeline } from "../pipeline"; import { Container } from "./Container"; @@ -20,6 +20,7 @@ export class ServiceRegistration { container.singleton(SemicolonRule); container.singleton(BracketSpacingRule); container.singleton(IndentationRule); + container.singleton(StructuralIndentationRule); container.singleton(BlockSpacingRule); container.singleton(DocBlockCommentRule); container.singleton(ImportOrganizationRule); diff --git a/src/core/formatters/rules/ast/ClassMemberSortingRule.ts b/src/core/formatters/rules/ast/ClassMemberSortingRule.ts index 691c38e..95593c6 100644 --- a/src/core/formatters/rules/ast/ClassMemberSortingRule.ts +++ b/src/core/formatters/rules/ast/ClassMemberSortingRule.ts @@ -10,7 +10,6 @@ import { BaseFormattingRule } from "../../BaseFormattingRule"; /** Types of class members */ - export enum MemberType { StaticProperty = "static_property", InstanceProperty = "instance_property", @@ -22,7 +21,6 @@ export enum MemberType { } /** Analyzed class member with metadata */ - export interface ClassMember { node: ts.ClassElement; type: MemberType; @@ -38,7 +36,6 @@ export interface ClassMember { } /** Default order for class members */ - export const DEFAULT_CLASS_ORDER: MemberType[] = [ MemberType.StaticProperty, @@ -51,7 +48,6 @@ export const DEFAULT_CLASS_ORDER: MemberType[] = [ ]; /** Sorts class members according to configured order */ - export class ClassMemberSortingRule extends BaseFormattingRule { readonly name = "ClassMemberSortingRule"; @@ -116,7 +112,7 @@ export class ClassMemberSortingRule extends BaseFormattingRule { text, dependencies, originalIndex: index, -}; + }; } private createSourceFile(source: string, filePath: string): ts.SourceFile { @@ -126,7 +122,6 @@ export class ClassMemberSortingRule extends BaseFormattingRule { /** Compare two class members for sorting */ private compareMembers(a: ClassMember, b: ClassMember, aTypeIndex: number, bTypeIndex: number): number { // First, sort by member type according to the defined order - if (aTypeIndex !== bTypeIndex) { return aTypeIndex - bTypeIndex; } @@ -173,7 +168,6 @@ export class ClassMemberSortingRule extends BaseFormattingRule { let formatted = source; // Find all class declarations and reorder their members - const classes: ts.ClassDeclaration[] = []; const visit = (node: ts.Node) => { if (ts.isClassDeclaration(node)) { @@ -184,7 +178,6 @@ export class ClassMemberSortingRule extends BaseFormattingRule { visit(sourceFile); // Process classes in reverse order to maintain correct positions - for (let i = classes.length - 1; i >= 0; i--) { const classNode = classes[i]; @@ -193,7 +186,6 @@ export class ClassMemberSortingRule extends BaseFormattingRule { } // Analyze and sort members - const allMemberNames = new Set(classNode.members .map(m => ASTAnalyzer.getClassMemberName(m)) @@ -211,7 +203,6 @@ export class ClassMemberSortingRule extends BaseFormattingRule { } // Check if reordering is needed - const orderChanged = sortedMembers.some((member, index) => member.originalIndex !== index); if (!orderChanged) { @@ -219,7 +210,6 @@ export class ClassMemberSortingRule extends BaseFormattingRule { } // Reconstruct class body with reordered members using original text - const firstMember = classNode.members[0]; const lastMember = classNode.members[classNode.members.length - 1]; const classBodyStart = firstMember.getFullStart(); @@ -230,7 +220,6 @@ export class ClassMemberSortingRule extends BaseFormattingRule { const newClassBody = memberTexts.join("\n\n"); // Replace the class body (add leading newline for proper spacing) - formatted = formatted.substring(0, classBodyStart) + "\n" + newClassBody + formatted.substring(classBodyEnd); } diff --git a/src/core/formatters/rules/ast/FileDeclarationSortingRule.ts b/src/core/formatters/rules/ast/FileDeclarationSortingRule.ts index e1c0a37..1ccc1c8 100644 --- a/src/core/formatters/rules/ast/FileDeclarationSortingRule.ts +++ b/src/core/formatters/rules/ast/FileDeclarationSortingRule.ts @@ -4,13 +4,12 @@ */ import * as ts from "typescript"; -import { ASTAnalyzer } from "../../../ast/ASTAnalyzer"; -import { DependencyResolver } from "../../../ast/DependencyResolver"; +import { ASTAnalyzer } from "../../../ast"; +import { DependencyResolver } from "../../../ast"; import { BaseFormattingRule } from "../../BaseFormattingRule"; /** Types of top-level declarations in a file */ - export enum DeclarationType { Interface = "interface", TypeAlias = "type_alias", @@ -25,7 +24,6 @@ export enum DeclarationType { } /** Analyzed file declaration with metadata */ - export interface FileDeclaration { node: ts.Statement; type: DeclarationType; @@ -38,7 +36,6 @@ export interface FileDeclaration { } /** Default order for file declarations */ - export const DEFAULT_FILE_ORDER: DeclarationType[] = [ DeclarationType.Interface, @@ -54,7 +51,6 @@ export const DEFAULT_FILE_ORDER: DeclarationType[] = [ ]; /** Sorts file-level declarations according to configured order */ - export class FileDeclarationSortingRule extends BaseFormattingRule { readonly name = "FileDeclarationSortingRule"; @@ -119,7 +115,7 @@ export class FileDeclarationSortingRule extends BaseFormattingRule { text, dependencies, originalIndex: index, -}; + }; } private createSourceFile(source: string, filePath: string): ts.SourceFile { @@ -170,7 +166,6 @@ export class FileDeclarationSortingRule extends BaseFormattingRule { } // Analyze and sort declarations - const allDeclarationNames = new Set(otherStatements.map(stmt => ASTAnalyzer.getDeclarationName(stmt)).filter(n => n)); @@ -187,7 +182,6 @@ export class FileDeclarationSortingRule extends BaseFormattingRule { } // Check if reordering is needed - const orderChanged = sortedDeclarations.some((decl, index) => decl.originalIndex !== index); if (!orderChanged) { @@ -195,7 +189,6 @@ export class FileDeclarationSortingRule extends BaseFormattingRule { } // Reconstruct file with reordered declarations using original text - const firstDeclaration = otherStatements[0]; const lastDeclaration = otherStatements[otherStatements.length - 1]; const declarationsStart = firstDeclaration.getFullStart(); @@ -206,11 +199,9 @@ export class FileDeclarationSortingRule extends BaseFormattingRule { const newDeclarations = declarationTexts.join("\n\n"); // Replace the declarations section (add spacing between imports and declarations) - let formatted = source.substring(0, declarationsStart) + "\n\n" + newDeclarations + source.substring(declarationsEnd); // Remove trailing semicolons that TypeScript printer adds after closing braces - formatted = formatted.replace(/(\n;)+\s*$/, "\n"); return formatted; diff --git a/src/core/formatters/rules/imports/ImportOrganizationRule.ts b/src/core/formatters/rules/imports/ImportOrganizationRule.ts index c6f6fb9..65a22b3 100644 --- a/src/core/formatters/rules/imports/ImportOrganizationRule.ts +++ b/src/core/formatters/rules/imports/ImportOrganizationRule.ts @@ -17,7 +17,6 @@ interface ImportInfo { } /** Organizes and formats import statements */ - export class ImportOrganizationRule extends BaseFormattingRule { readonly name = "ImportOrganizationRule"; @@ -54,7 +53,7 @@ export class ImportOrganizationRule extends BaseFormattingRule { isTypeOnly, isSideEffect, group, -}); + }); } } @@ -68,12 +67,10 @@ export class ImportOrganizationRule extends BaseFormattingRule { return identifiers; } // Default import - if (importInfo.importClause.name) { identifiers.push(importInfo.importClause.name.text); } // Named imports - if (importInfo.importClause.namedBindings) { if (ts.isNamedImports(importInfo.importClause.namedBindings)) { for (const element of importInfo.importClause.namedBindings.elements) { @@ -97,7 +94,6 @@ export class ImportOrganizationRule extends BaseFormattingRule { if (ts.isIdentifier(node) && node.text === identifier) { // Make sure it's not the import declaration itself - const parent = node.parent; if (!ts.isImportSpecifier(parent) && !ts.isImportClause(parent)) { @@ -113,7 +109,6 @@ export class ImportOrganizationRule extends BaseFormattingRule { private isImportUsed(importInfo: ImportInfo, sourceFile: ts.SourceFile): boolean { // Side-effect imports are considered "used" - if (importInfo.isSideEffect) { return true; } @@ -133,7 +128,6 @@ export class ImportOrganizationRule extends BaseFormattingRule { return imports; } // Don't remove side-effect imports unless configured - if (!config.removeSideEffects) { return imports.filter(imp => imp.isSideEffect || this.isImportUsed(imp, sourceFile)); } @@ -181,7 +175,6 @@ export class ImportOrganizationRule extends BaseFormattingRule { let leadingComments = leadingCommentsMatch ? leadingCommentsMatch[1].trim() : ""; // Deduplicate consecutive identical block comments (fixes copyright duplication) - if (leadingComments) { const commentBlocks = leadingComments.match(/\/\*[\s\S]*?\*\//g) || []; const uniqueBlocks = new Set(commentBlocks.map(block => block.trim())); @@ -190,16 +183,13 @@ export class ImportOrganizationRule extends BaseFormattingRule { } // Find the last import statement position - const importStatements = sourceFile.statements.filter(stmt => ts.isImportDeclaration(stmt)); const lastImport = importStatements[importStatements.length - 1]; const afterImportsPos = lastImport ? lastImport.getEnd() : (leadingCommentsMatch ? leadingCommentsMatch[0].length : 0); // Extract everything after imports, preserving original formatting - let restOfFile = fullText.substring(afterImportsPos); // Ensure restOfFile starts with a newline - if (restOfFile && !restOfFile.startsWith("\n")) { restOfFile = "\n" + restOfFile; } @@ -207,11 +197,10 @@ export class ImportOrganizationRule extends BaseFormattingRule { restOfFile = restOfFile.replace(/^\n{2,}/, "\n"); // Build import section (only reprint imports, not everything else) - const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed, removeComments: false, -}); + }); const importLines: string[] = []; @@ -219,26 +208,22 @@ export class ImportOrganizationRule extends BaseFormattingRule { for (const importInfo of imports) { // Add blank line between groups if configured - if (config?.separateGroups && - lastGroup !== null && lastGroup !== importInfo.group) { importLines.push(""); - } + } let importText = printer.printNode(ts.EmitHint.Unspecified, importInfo.statement, sourceFile); // Strip any leading block comments from individual imports // (file-level copyright is handled separately at the top) - importText = importText.replace(/^((?:\/\*[\s\S]*?\*\/\s*)+)/, "").trim(); importLines.push(importText); lastGroup = importInfo.group; } // Combine sections - const sections: string[] = []; if (leadingComments) { @@ -256,7 +241,6 @@ export class ImportOrganizationRule extends BaseFormattingRule { let combined = sections.join("\n\n"); // Remove trailing semicolons that TypeScript printer adds after closing braces - combined = combined.replace(/(;\n+)+;?\s*$/, "\n"); return combined; @@ -273,7 +257,6 @@ export class ImportOrganizationRule extends BaseFormattingRule { // Apply all transformations in sequence // Each method checks its own config and returns early if disabled - let processedImports = this.filterUnusedImports(imports, sourceFile); processedImports = this.sortImports(processedImports); diff --git a/src/core/formatters/rules/index-generation/IndexGenerationRule.ts b/src/core/formatters/rules/index-generation/IndexGenerationRule.ts index 82ef7b9..07238e3 100644 --- a/src/core/formatters/rules/index-generation/IndexGenerationRule.ts +++ b/src/core/formatters/rules/index-generation/IndexGenerationRule.ts @@ -42,7 +42,7 @@ export class IndexGenerationRule extends BaseFormattingRule { fileExtension: ".ts", indexFileName: "index.ts", recursive: true -}; + }; readonly name = "IndexGenerationRule"; @@ -61,9 +61,7 @@ export class IndexGenerationRule extends BaseFormattingRule { private isTestDirectory(dirName: string): boolean { // Common test directory patterns (not configurable) - const testDirectories = [ - "__tests__", "tests", "test", @@ -79,9 +77,7 @@ export class IndexGenerationRule extends BaseFormattingRule { private isTestFile(fileName: string): boolean { // Common test file patterns (not configurable) - const testPatterns = [ - /\.test\.(ts|tsx|js|jsx)$/, /\.spec\.(ts|tsx|js|jsx)$/, /\.(test|spec)\.(ts|tsx|js|jsx)$/, @@ -108,7 +104,6 @@ export class IndexGenerationRule extends BaseFormattingRule { continue; } // Check if subdirectory has an index file - const subIndexPath = path.join(dir, entry.name, options.indexFileName); if (fs.existsSync(subIndexPath)) { @@ -116,12 +111,10 @@ export class IndexGenerationRule extends BaseFormattingRule { } } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) { // Skip .d.ts files when processing regular .ts files - if (entry.name.endsWith(".d.ts")) { continue; } // Always skip test files (not configurable) - if (this.isTestFile(entry.name)) { continue; } @@ -194,13 +187,11 @@ ${exports.join("\n")} for (const entry of entries) { // Skip index.ts itself - if (entry.name === "index.ts") { continue; } // Check for directories with index.ts - if (entry.isDirectory()) { // Always skip test directories (not configurable) if (this.isTestDirectory(entry.name)) { @@ -214,7 +205,6 @@ ${exports.join("\n")} } } // Check for .d.ts files (like generated.d.ts) - else if (entry.name.endsWith(".d.ts")) { const moduleName = entry.name.slice(0, -3); // Remove .ts but keep .d @@ -265,7 +255,6 @@ ${exports} } // Update main src/index.ts if configured - if (config?.updateMainIndex !== false) { const srcDir = path.join(projectRoot, "src"); const mainIndexPath = path.join(srcDir, "index.ts"); diff --git a/src/core/formatters/rules/spacing/BlankLineBeforeReturnsRule.ts b/src/core/formatters/rules/spacing/BlankLineBeforeReturnsRule.ts index bbbfccf..19849bd 100644 --- a/src/core/formatters/rules/spacing/BlankLineBeforeReturnsRule.ts +++ b/src/core/formatters/rules/spacing/BlankLineBeforeReturnsRule.ts @@ -10,7 +10,6 @@ import { BaseFormattingRule } from "../../BaseFormattingRule"; * Adds blank lines before return statements * Works at all brace depths (not just top level) */ - export class BlankLineBeforeReturnsRule extends BaseFormattingRule { readonly name = "BlankLineBeforeReturnsRule"; diff --git a/src/core/formatters/rules/spacing/BlankLineBetweenDeclarationsRule.ts b/src/core/formatters/rules/spacing/BlankLineBetweenDeclarationsRule.ts index 84a3c23..4976fbe 100644 --- a/src/core/formatters/rules/spacing/BlankLineBetweenDeclarationsRule.ts +++ b/src/core/formatters/rules/spacing/BlankLineBetweenDeclarationsRule.ts @@ -96,13 +96,11 @@ export class BlankLineBetweenDeclarationsRule extends BaseFormattingRule { const isDeclarationStart = declarationKeyword !== null; // Check if we've left the import section - if (inImportSection && !isImport && !isBlankLine && !isComment) { inImportSection = false; } // KEY ENHANCEMENT: Removed "braceDepth === 0" check // Now works at ALL depths, not just top level - if (!inImportSection) { // Add blank line before block comments that precede declarations if (isBlockCommentStart && @@ -112,9 +110,8 @@ export class BlankLineBetweenDeclarationsRule extends BaseFormattingRule { result[result.length - 1].trim() !== "") { result.push(""); lastNonBlankLineWasDeclarationEnd = false; - } + } // Add blank line before declaration starts ONLY if the keyword is different - else if (isDeclarationStart && lastNonBlankLineWasDeclarationEnd && @@ -123,11 +120,10 @@ export class BlankLineBetweenDeclarationsRule extends BaseFormattingRule { declarationKeyword !== lastDeclarationKeyword) { result.push(""); lastNonBlankLineWasDeclarationEnd = false; - } + } } result.push(line); // Track declaration ends BEFORE updating brace depth - const hasClosingElement = trimmedLine === "}" || trimmedLine.endsWith("}") || @@ -138,13 +134,11 @@ export class BlankLineBetweenDeclarationsRule extends BaseFormattingRule { if (!isBlankLine && hasClosingElement) { lastNonBlankLineWasDeclarationEnd = true; // Update the last declaration keyword when a declaration ends - if (isDeclarationStart) { lastDeclarationKeyword = declarationKeyword; } } else if (!isBlankLine && !isComment) { // Don't reset if the line is just closing braces - if (!isBlockCommentStart && trimmedLine !== "" && !isJustClosingBraces) { lastNonBlankLineWasDeclarationEnd = isDeclarationStart; diff --git a/src/core/formatters/rules/spacing/BlankLineBetweenStatementTypesRule.ts b/src/core/formatters/rules/spacing/BlankLineBetweenStatementTypesRule.ts index 57398b6..70fdeb4 100644 --- a/src/core/formatters/rules/spacing/BlankLineBetweenStatementTypesRule.ts +++ b/src/core/formatters/rules/spacing/BlankLineBetweenStatementTypesRule.ts @@ -7,7 +7,6 @@ import { BaseFormattingRule } from "../../BaseFormattingRule"; /** Statement types for categorization */ - enum StatementType { Declaration = "declaration",// const, let, var, function, class, etc. Control = "control",// if, else, switch, case @@ -31,7 +30,6 @@ export class BlankLineBetweenStatementTypesRule extends BaseFormattingRule { /** Determine the type of a statement */ private getStatementType(trimmedLine: string): StatementType { // Control flow - if (trimmedLine.startsWith("if ") || trimmedLine.startsWith("if(") || @@ -42,9 +40,8 @@ export class BlankLineBetweenStatementTypesRule extends BaseFormattingRule { trimmedLine.startsWith("case ") || trimmedLine.startsWith("default:")) { return StatementType.Control; - } + } // Loops - if (trimmedLine.startsWith("for ") || trimmedLine.startsWith("for(") || @@ -53,9 +50,8 @@ export class BlankLineBetweenStatementTypesRule extends BaseFormattingRule { trimmedLine.startsWith("do ") || trimmedLine.startsWith("do{")) { return StatementType.Loop; - } + } // Exceptions - if (trimmedLine.startsWith("try ") || trimmedLine.startsWith("try{") || @@ -65,9 +61,8 @@ export class BlankLineBetweenStatementTypesRule extends BaseFormattingRule { trimmedLine.startsWith("finally{") || trimmedLine.startsWith("throw ")) { return StatementType.Exception; - } + } // Declarations - if (trimmedLine.startsWith("const ") || trimmedLine.startsWith("let ") || @@ -79,7 +74,7 @@ export class BlankLineBetweenStatementTypesRule extends BaseFormattingRule { trimmedLine.startsWith("enum ") || trimmedLine.startsWith("export ")) { return StatementType.Declaration; - } + } // Everything else (expressions, calls, etc.) return StatementType.Expression; } @@ -107,28 +102,24 @@ export class BlankLineBetweenStatementTypesRule extends BaseFormattingRule { const isImport = trimmedLine.startsWith("import "); // Check if we've left the import section - if (inImportSection && !isImport && !isBlankLine && !isComment) { inImportSection = false; } // Skip import section - if (inImportSection || isBlankLine || isComment) { result.push(line); continue; } // Get statement type for non-blank, non-comment lines - const currentStatementType = this.getStatementType(trimmedLine); // Add blank line if statement type changed - if (lastStatementType !== null && lastStatementType !== currentStatementType && result.length > 0 && result[result.length - 1].trim() !== "") { result.push(""); - } + } result.push(line); lastStatementType = currentStatementType; } diff --git a/src/core/formatters/rules/spacing/BlockSpacingRule.ts b/src/core/formatters/rules/spacing/BlockSpacingRule.ts index 2c92491..5332ee2 100644 --- a/src/core/formatters/rules/spacing/BlockSpacingRule.ts +++ b/src/core/formatters/rules/spacing/BlockSpacingRule.ts @@ -21,7 +21,6 @@ export class BlockSpacingRule extends BaseFormattingRule { // Remove blank lines after opening braces of interfaces, classes, enums, functions // Pattern: { followed by newlines and whitespace before content - result = result.replace(/\{\n\n+(\s*(?:\/\*\*|[a-zA-Z_]))/g, "{\n$1"); // Remove blank lines between JSDoc comments and what they describe diff --git a/src/core/formatters/rules/spacing/BracketSpacingRule.ts b/src/core/formatters/rules/spacing/BracketSpacingRule.ts index 4ef95ff..3e3bb97 100644 --- a/src/core/formatters/rules/spacing/BracketSpacingRule.ts +++ b/src/core/formatters/rules/spacing/BracketSpacingRule.ts @@ -33,7 +33,6 @@ export class BracketSpacingRule extends BaseFormattingRule { const fullText = sourceFile.getFullText(); const visit = (node: ts.Node) => { // Handle object literals - if (ts.isObjectLiteralExpression(node)) { const openBraceEnd = node.getStart(sourceFile) + 1; // Position after '{' const closeBraceStart = node.getEnd() - 1; // Position of '}' @@ -41,14 +40,12 @@ export class BracketSpacingRule extends BaseFormattingRule { if (node.properties.length > 0) { if (config.bracketSpacing) { // Add spacing after opening brace - const afterOpenBrace = fullText[openBraceEnd]; if (afterOpenBrace !== " " && afterOpenBrace !== "\n") { changes.push({pos: openBraceEnd, type: "add", text: " "}); } // Add spacing before closing brace - const beforeCloseBrace = fullText[closeBraceStart - 1]; if (beforeCloseBrace !== " " && beforeCloseBrace !== "\n") { @@ -56,7 +53,6 @@ export class BracketSpacingRule extends BaseFormattingRule { } } else { // Remove spacing after opening brace - let pos = openBraceEnd; while (fullText[pos] === " " || fullText[pos] === "\t") { @@ -74,7 +70,6 @@ export class BracketSpacingRule extends BaseFormattingRule { } } // Handle named imports - if (ts.isNamedImports(node)) { const parent = node.parent; @@ -85,14 +80,12 @@ export class BracketSpacingRule extends BaseFormattingRule { if (node.elements.length > 0) { if (config.bracketSpacing) { // Add spacing after opening brace - const afterOpenBrace = fullText[openBraceEnd]; if (afterOpenBrace !== " ") { changes.push({pos: openBraceEnd, type: "add", text: " "}); } // Add spacing before closing brace - const beforeCloseBrace = fullText[closeBraceStart - 1]; if (beforeCloseBrace !== " ") { @@ -100,7 +93,6 @@ export class BracketSpacingRule extends BaseFormattingRule { } } else { // Remove spacing after opening brace - let pos = openBraceEnd; while (fullText[pos] === " " || fullText[pos] === "\t") { diff --git a/src/core/formatters/rules/style/IndentationRule.ts b/src/core/formatters/rules/style/IndentationRule.ts index 424ae12..d01c68d 100644 --- a/src/core/formatters/rules/style/IndentationRule.ts +++ b/src/core/formatters/rules/style/IndentationRule.ts @@ -31,36 +31,30 @@ export class IndentationRule extends BaseFormattingRule { for (const line of lines) { // Skip empty lines - if (line.trim() === "") { result.push(line); continue; } // Get the leading whitespace - const leadingWhitespace = line.match(/^\s*/)?.[0] || ""; const content = line.substring(leadingWhitespace.length); // Calculate indent level based on current whitespace - let indentLevel = 0; if (config.indentStyle === "space") { // Count tabs and spaces - const tabCount = (leadingWhitespace.match(/\t/g) || []).length; const spaceCount = (leadingWhitespace.match(/ /g) || []).length; indentLevel = tabCount + Math.floor(spaceCount / indentWidth); } else { // Converting to tabs - const spaceCount = (leadingWhitespace.match(/ /g) || []).length; const tabCount = (leadingWhitespace.match(/\t/g) || []).length; indentLevel = tabCount + Math.floor(spaceCount / indentWidth); } // Create new indentation - let newIndent: string; if (config.indentStyle === "space") { diff --git a/src/core/formatters/rules/style/QuoteStyleRule.ts b/src/core/formatters/rules/style/QuoteStyleRule.ts index 5407137..7f090ff 100644 --- a/src/core/formatters/rules/style/QuoteStyleRule.ts +++ b/src/core/formatters/rules/style/QuoteStyleRule.ts @@ -28,7 +28,6 @@ export class QuoteStyleRule extends BaseFormattingRule { const visit = (node: ts.Node) => { // Handle string literals (but not template literals) - if (ts.isStringLiteral(node)) { const nodeText = node.getText(sourceFile); const currentQuote = nodeText[0]; @@ -37,12 +36,10 @@ export class QuoteStyleRule extends BaseFormattingRule { if (currentQuote !== desiredQuote) { // Get the string content (without quotes) - const content = node.text; // Check if the new quote style would require escaping const needsEscape = content.includes(desiredQuote); // If it needs escaping, skip this string literal - if (!needsEscape) { const newText = desiredQuote + content + desiredQuote; @@ -50,7 +47,7 @@ export class QuoteStyleRule extends BaseFormattingRule { start: node.getStart(sourceFile), end: node.getEnd(), text: newText, -}); + }); } } } diff --git a/src/core/formatters/rules/style/SemicolonRule.ts b/src/core/formatters/rules/style/SemicolonRule.ts index ec11e46..ea0fb29 100644 --- a/src/core/formatters/rules/style/SemicolonRule.ts +++ b/src/core/formatters/rules/style/SemicolonRule.ts @@ -27,7 +27,6 @@ export class SemicolonRule extends BaseFormattingRule { const visit = (node: ts.Node) => { // Check statements that should have semicolons // NOTE: Interfaces, classes, and enums should NOT have semicolons after their closing braces - if (ts.isVariableStatement(node) || ts.isExpressionStatement(node) || @@ -44,17 +43,14 @@ export class SemicolonRule extends BaseFormattingRule { if (config.semicolons === "always" && !hasSemicolon) { // Add semicolon - changes.push({pos: nodeEnd, type: "add"}); } else if (config.semicolons === "never" && hasSemicolon) { // Remove semicolon - changes.push({pos: nodeEnd - 1, type: "remove"}); } - } + } // Remove incorrect semicolons from interfaces, classes, and enums - if (ts.isInterfaceDeclaration(node) || ts.isClassDeclaration(node) || ts.isEnumDeclaration(node)) { const nodeEnd = node.getEnd(); const fullText = sourceFile.getFullText(); @@ -62,7 +58,6 @@ export class SemicolonRule extends BaseFormattingRule { if (hasSemicolon) { // Remove the incorrect semicolon after the closing brace - changes.push({pos: nodeEnd, type: "remove"}); } } diff --git a/src/core/formatters/rules/style/StructuralIndentationRule.ts b/src/core/formatters/rules/style/StructuralIndentationRule.ts new file mode 100644 index 0000000..b6e1816 --- /dev/null +++ b/src/core/formatters/rules/style/StructuralIndentationRule.ts @@ -0,0 +1,353 @@ +/* +* Copyright (c) 2026. Encore Digital Group. +* All Rights Reserved. +*/ + +import { BaseFormattingRule } from "../../BaseFormattingRule"; + + +/** A fix to apply to a closing bracket */ +interface BracketFix { + position: number; + line: number; + column: number; + targetIndent: number; +} + +/** Information about an opening bracket */ +interface BracketInfo { + char: string; + position: number; + line: number; + lineIndent: number; +} + +/** +* Fixes structural indentation issues where closing braces/brackets +* are not properly aligned with their opening statements. +* +* Uses a stack-based approach to properly match opening and closing +* brackets, ensuring each closing bracket is indented to match its +* corresponding opening bracket's line indentation. +*/ +export class StructuralIndentationRule extends BaseFormattingRule { +readonly name = "StructuralIndentationRule"; + +private skipString(source: string, start: number, quote: string): { pos: number; newlines: number } { + let i = start + 1; + let newlines = 0; + const isTemplate = quote === "`"; + + while (i < source.length) { + const char = source[i]; + + // Count newlines + if (char === "\n") { + newlines++; + i++; + continue; + } + + // Handle escape sequences + if (char === "\\") { + i += 2; + continue; + } + + // Handle template literal expressions ${...} + if (isTemplate && char === "$" && source[i + 1] === "{") { + i += 2; + let braceCount = 1; + while (i < source.length && braceCount > 0) { + if (source[i] === "\n") { + newlines++; + } else if (source[i] === "{") { + braceCount++; + } else if (source[i] === "}") { + braceCount--; + } else if (source[i] === '"' || source[i] === "'" || source[i] === "`") { + const result = this.skipString(source, i, source[i]); + i = result.pos; + newlines += result.newlines; + continue; + } + i++; + } + continue; + } + + // End of string + if (char === quote) { + return {pos: i + 1, newlines}; + } + + i++; + } + + return {pos: i, newlines}; +} + +private isRegexStart(source: string, index: number): boolean { + // Look backwards to determine if this / starts a regex + let i = index - 1; + while (i >= 0 && (source[i] === " " || source[i] === "\t")) { + i--; + } + + if (i < 0) return true; + + const char = source[i]; + // After these characters, / is likely a regex + const regexPreceders = ["(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "\n", "return", "case"]; + + if (regexPreceders.includes(char)) { + return true; + } + + // Check for keywords + const keywords = ["return", "case", "typeof", "void", "delete", "throw", "in", "instanceof"]; + for (const kw of keywords) { + if (index >= kw.length && source.substring(index - kw.length, index).endsWith(kw)) { + return true; + } + } + + return false; +} + +private skipRegex(source: string, start: number): number { + let i = start + 1; + let inCharClass = false; + + while (i < source.length) { + const char = source[i]; + + if (char === "\\") { + i += 2; + continue; + } + + if (char === "[") { + inCharClass = true; + } else if (char === "]") { + inCharClass = false; + } else if (char === "/" && !inCharClass) { + // Skip flags + i++; + while (i < source.length && /[gimsuy]/.test(source[i])) { + i++; + } + return i; + } else if (char === "\n") { + // Regex can't span lines without escaping + return i; + } + + i++; + } + + return i; +} + +private getLineIndentLevel(line: string, indentWidth: number): number { + const leadingWhitespace = line.match(/^[\t ]*/)?.[0] || ""; + + const tabCount = (leadingWhitespace.match(/\t/g) || []).length; + const spaceCount = (leadingWhitespace.match(/ /g) || []).length; + + return tabCount + Math.floor(spaceCount / indentWidth); +} + +private startsWithClosingBracket(trimmedLine: string): boolean { + return /^[}\])]/.test(trimmedLine); +} + +private findBracketFixes(source: string, lines: string[], indentWidth: number): BracketFix[] { + const fixes: BracketFix[] = []; + const stack: BracketInfo[] = []; + // Track corrected indentation for lines that have fixes + const lineIndentCorrections = new Map(); + + let i = 0; + let line = 0; + let column = 0; + let lineStart = 0; + + const openBrackets: Record = { + "{": "}", + "[": "]", + "(": ")" + }; + + const closeBrackets: Record = { + "}": "{", + "]": "[", + ")": "(" + }; + + while (i < source.length) { + const char = source[i]; + + // Handle newlines + if (char === "\n") { + line++; + column = 0; + lineStart = i + 1; + i++; + continue; + } + + // Skip string literals + if (char === '"' || char === "'" || char === "`") { + const result = this.skipString(source, i, char); + i = result.pos; + line += result.newlines; + if (result.newlines > 0) { + // Find the last newline position to update lineStart + let lastNewline = i - 1; + while (lastNewline >= 0 && source[lastNewline] !== "\n") { + lastNewline--; + } + lineStart = lastNewline + 1; + } + column = i - lineStart; + continue; + } + + // Skip single-line comments + if (char === "/" && source[i + 1] === "/") { + while (i < source.length && source[i] !== "\n") { + i++; + } + continue; + } + + // Skip multi-line comments + if (char === "/" && source[i + 1] === "*") { + i += 2; + while (i < source.length - 1 && !(source[i] === "*" && source[i + 1] === "/")) { + if (source[i] === "\n") { + line++; + lineStart = i + 1; + } + i++; + } + i += 2; + column = i - lineStart; + continue; + } + + // Skip regex literals (basic detection) + if (char === "/" && this.isRegexStart(source, i)) { + i = this.skipRegex(source, i); + column = i - lineStart; + continue; + } + + // Handle opening brackets + if (openBrackets[char]) { + // Use corrected indent if this line has a fix, otherwise use current indent + const lineIndent = lineIndentCorrections.has(line) + ? lineIndentCorrections.get(line)! + : this.getLineIndentLevel(lines[line], indentWidth); + stack.push({ + char, + position: i, + line, + lineIndent + }); + } + + // Handle closing brackets + if (closeBrackets[char]) { + const expectedOpen = closeBrackets[char]; + // Find matching opening bracket + let matchIndex = -1; + for (let j = stack.length - 1; j >= 0; j--) { + if (stack[j].char === expectedOpen) { + matchIndex = j; + break; + } + } + + if (matchIndex !== -1) { + const openBracket = stack[matchIndex]; + stack.splice(matchIndex, 1); + + // Only fix if the closing bracket is on a different line + if (line !== openBracket.line) { + const currentIndent = this.getLineIndentLevel(lines[line], indentWidth); + const trimmedLine = lines[line].trimStart(); + + // Only fix if this is a line that starts with closing brackets + if (this.startsWithClosingBracket(trimmedLine)) { + if (currentIndent !== openBracket.lineIndent) { + fixes.push({ + position: i, + line, + column, + targetIndent: openBracket.lineIndent + }); + // Record the corrected indent for this line + // so any opening brackets on this line use the corrected value + if (!lineIndentCorrections.has(line)) { + lineIndentCorrections.set(line, openBracket.lineIndent); + } + } + } + } + } + } + + i++; + column++; + } + + return fixes; +} + +apply(source: string, filePath?: string): string { + const config = this.getCodeStyleConfig(); + if (!config?.indentStyle || !config.indentWidth) { + return source; + } + + const indentWidth = config.indentWidth; + const indentUnit = config.indentStyle === "tab" ? "\t" : " ".repeat(indentWidth); + + const lines = source.split("\n"); + const fixes = this.findBracketFixes(source, lines, indentWidth); + + if (fixes.length === 0) { + return source; + } + + // Group fixes by line number + const fixesByLine = new Map(); + for (const fix of fixes) { + if (!fixesByLine.has(fix.line)) { + fixesByLine.set(fix.line, []); + } + fixesByLine.get(fix.line)!.push(fix); + } + + // Apply fixes line by line + const result: string[] = []; + for (let i = 0; i < lines.length; i++) { + const lineFixes = fixesByLine.get(i); + if (lineFixes && lineFixes.length > 0) { + // For lines with closing brackets, use the indent of the first (outermost) bracket + // Sort by column to get the leftmost bracket first + lineFixes.sort((a, b) => a.column - b.column); + const primaryFix = lineFixes[0]; + const trimmedLine = lines[i].trimStart(); + const newIndent = indentUnit.repeat(primaryFix.targetIndent); + result.push(newIndent + trimmedLine); + } else { + result.push(lines[i]); + } + } + + return result.join("\n"); +} +} diff --git a/src/core/formatters/rules/style/__tests__/DocBlockCommentRule.test.ts b/src/core/formatters/rules/style/__tests__/DocBlockCommentRule.test.ts index 7caf882..371469e 100644 --- a/src/core/formatters/rules/style/__tests__/DocBlockCommentRule.test.ts +++ b/src/core/formatters/rules/style/__tests__/DocBlockCommentRule.test.ts @@ -24,13 +24,13 @@ describe("DocBlockCommentRule", () => { indentation: { type: "spaces", size: 4 -}, + }, blockSpacing: true, docBlockComments: { consolidateSingleLine: true -} -} -} as CoreConfig; + } + } + } as CoreConfig; container.singleton(config); rule = new DocBlockCommentRule(container); }); diff --git a/src/core/formatters/rules/style/__tests__/StructuralIndentationRule.test.ts b/src/core/formatters/rules/style/__tests__/StructuralIndentationRule.test.ts new file mode 100644 index 0000000..86c0e16 --- /dev/null +++ b/src/core/formatters/rules/style/__tests__/StructuralIndentationRule.test.ts @@ -0,0 +1,509 @@ +/* +* Copyright (c) 2026. Encore Digital Group. +* All Rights Reserved. +*/ + +// tsfmt-ignore + +import { CoreConfig } from "../../../../config"; +import { Container } from "../../../../di"; +import { StructuralIndentationRule } from "../StructuralIndentationRule"; + + +describe("StructuralIndentationRule", () => { + let rule: StructuralIndentationRule; + let container: Container; + let config: CoreConfig; + + beforeEach(() => { + container = new Container(); + config = { + codeStyle: { + enabled: true, + indentStyle: "space", + indentWidth: 4 + } + } as CoreConfig; + container.singleton(config); + rule = new StructuralIndentationRule(container); + }); + + describe("apply", () => { + it("should fix a single misaligned closing brace", () => { + // Input has }; at column 0, should be at 4 spaces + const input = [ + "function test() {", + " const obj = {", + " a: 1", + "};", // Wrong: at column 0 + " return obj;", + "}" + ].join("\n"); + + const expected = [ + "function test() {", + " const obj = {", + " a: 1", + " };", // Correct: at 4 spaces + " return obj;", + "}" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(expected); + }); + + it("should fix nested misaligned closing braces", () => { + const input = [ + "function test() {", + " const outer = {", + " inner: {", + " value: 1", + "}", // Wrong + "};", // Wrong + " return outer;", + "}" + ].join("\n"); + + const expected = [ + "function test() {", + " const outer = {", + " inner: {", + " value: 1", + " }", // Fixed to 8 spaces + " };", // Fixed to 4 spaces + " return outer;", + "}" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(expected); + }); + + it("should fix deeply nested structures", () => { + const input = [ + "const obj = {", + " a: {", + " b: {", + " c: {", + " d: 1", + "}", // Wrong + "}", // Wrong + "}", // Wrong + "};" + ].join("\n"); + + const expected = [ + "const obj = {", + " a: {", + " b: {", + " c: {", + " d: 1", + " }", // 12 spaces + " }", // 8 spaces + " }", // 4 spaces + "};" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(expected); + }); + + it("should fix misaligned array brackets", () => { + const input = [ + "const arr = [", + " 1,", + " 2,", + " 3", + "];" // Already correct at column 0 + ].join("\n"); + + // No change expected - array at top level, ] at column 0 is correct + const result = rule.apply(input); + expect(result).toBe(input); + }); + + it("should fix nested array brackets", () => { + const input = [ + "const matrix = [", + " [1, 2],", + " [3, 4]", + "];" // Already correct + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(input); + }); + + it("should fix mixed bracket types", () => { + const input = [ + "function test() {", + " const obj = {", + " arr: [", + " 1,", + " 2", + "]", // Wrong + "};", // Wrong + " return obj;", + "}" + ].join("\n"); + + const expected = [ + "function test() {", + " const obj = {", + " arr: [", + " 1,", + " 2", + " ]", // 8 spaces + " };", // 4 spaces + " return obj;", + "}" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(expected); + }); + + it("should not modify correctly indented code", () => { + const input = [ + "function test() {", + " const obj = {", + " a: 1,", + " b: 2", + " };", + " return obj;", + "}" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(input); + }); + + it("should ignore braces inside string literals", () => { + const input = [ + 'const str = "{ not a real brace }";', + "const obj = {", + " value: 1", + "};" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(input); + }); + + it("should ignore braces inside template literals", () => { + const input = [ + "const template = `{", + " fake brace", + "}`;", + "const obj = {", + " value: 1", + "};" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(input); + }); + + it("should ignore braces inside single-line comments", () => { + const input = [ + "// { comment brace }", + "const obj = {", + " value: 1", + "};" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(input); + }); + + it("should ignore braces inside multi-line comments", () => { + const input = [ + "/*", + " * { comment brace }", + " */", + "const obj = {", + " value: 1", + "};" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(input); + }); + + it("should handle class declarations", () => { + const input = [ + "class Example {", + " method() {", + " return {", + " a: 1", + "};", // Wrong: should be 8 spaces + "}", // Wrong: should be 4 spaces + "}" + ].join("\n"); + + const expected = [ + "class Example {", + " method() {", + " return {", + " a: 1", + " };", // 8 spaces + " }", // 4 spaces + "}" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(expected); + }); + + it("should handle if statements", () => { + const input = [ + "function test() {", + " if (condition) {", + " doSomething();", + "}", // Wrong + "}" + ].join("\n"); + + const expected = [ + "function test() {", + " if (condition) {", + " doSomething();", + " }", // 4 spaces + "}" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(expected); + }); + + it("should handle arrow functions", () => { + const input = [ + "const fn = () => {", + " return {", + " value: 1", + "};", // Wrong + "};" + ].join("\n"); + + const expected = [ + "const fn = () => {", + " return {", + " value: 1", + " };", // 4 spaces + "};" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(expected); + }); + + it("should handle empty source code", () => { + const result = rule.apply(""); + expect(result).toBe(""); + }); + + it("should handle source code without braces", () => { + const input = [ + "const x = 1;", + "const y = 2;" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(input); + }); + + it("should handle single-line objects (no change needed)", () => { + const input = "const obj = { a: 1, b: 2 };"; + + const result = rule.apply(input); + expect(result).toBe(input); + }); + + it("should handle multiple closing brackets on same line at column 0", () => { + const input = [ + "const obj = {", + " nested: {", + " value: 1", + "}};", // Two brackets at wrong position + ].join("\n"); + + const expected = [ + "const obj = {", + " nested: {", + " value: 1", + " }};", // Fixed: leftmost bracket determines indent (4 spaces) + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(expected); + }); + + it("should preserve trailing content after closing braces", () => { + const input = [ + "const obj = {", + " a: 1", + "}; // trailing comment" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(input); + }); + + it("should handle interface declarations", () => { + const input = [ + "interface Example {", + " prop: {", + " nested: string;", + "};", // Wrong + "}" + ].join("\n"); + + const expected = [ + "interface Example {", + " prop: {", + " nested: string;", + " };", // 4 spaces + "}" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(expected); + }); + + it("should handle try-catch blocks", () => { + const input = [ + "function test() {", + " try {", + " doSomething();", + "} catch (e) {", // Wrong: } should be at 4 spaces + " handleError();", + "}", // Wrong + "}" + ].join("\n"); + + const expected = [ + "function test() {", + " try {", + " doSomething();", + " } catch (e) {", // 4 spaces (matches try) + " handleError();", + " }", // 4 spaces (matches catch) + "}" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(expected); + }); + + it("should handle multi-line template literals correctly", () => { + // This tests that the rule handles newlines inside template literals + const input = [ + "function log() {", + " const msg = `Line 1", + "Line 2", + "Line 3`;", + " const obj = {", + " a: 1", + "};", // Wrong + "}" + ].join("\n"); + + const expected = [ + "function log() {", + " const msg = `Line 1", + "Line 2", + "Line 3`;", + " const obj = {", + " a: 1", + " };", // 4 spaces + "}" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(expected); + }); + }); + + describe("with tab indentation", () => { + beforeEach(() => { + config = { + codeStyle: { + enabled: true, + indentStyle: "tab", + indentWidth: 4 + } + } as CoreConfig; + container = new Container(); + container.singleton(config); + rule = new StructuralIndentationRule(container); + }); + + it("should fix misaligned braces using tabs", () => { + const input = [ + "function test() {", + "\tconst obj = {", + "\t\ta: 1", + "};", // Wrong + "\treturn obj;", + "}" + ].join("\n"); + + const expected = [ + "function test() {", + "\tconst obj = {", + "\t\ta: 1", + "\t};", // 1 tab + "\treturn obj;", + "}" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(expected); + }); + }); + + describe("disabled config", () => { + it("should return source unchanged when indentStyle is not set", () => { + config = { + codeStyle: { + enabled: true, + indentWidth: 4 + } + } as CoreConfig; + container = new Container(); + container.singleton(config); + rule = new StructuralIndentationRule(container); + + const input = [ + "const obj = {", + " a: 1", + "};" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(input); + }); + + it("should return source unchanged when indentWidth is not set", () => { + config = { + codeStyle: { + enabled: true, + indentStyle: "space" + } + } as CoreConfig; + container = new Container(); + container.singleton(config); + rule = new StructuralIndentationRule(container); + + const input = [ + "const obj = {", + " a: 1", + "};" + ].join("\n"); + + const result = rule.apply(input); + expect(result).toBe(input); + }); + }); +}); diff --git a/src/core/formatters/rules/style/index.ts b/src/core/formatters/rules/style/index.ts index 9becda1..817dccc 100644 --- a/src/core/formatters/rules/style/index.ts +++ b/src/core/formatters/rules/style/index.ts @@ -5,3 +5,4 @@ export * from "./DocBlockCommentRule"; export * from "./IndentationRule"; export * from "./QuoteStyleRule"; export * from "./SemicolonRule"; +export * from "./StructuralIndentationRule"; diff --git a/src/core/pipeline/FormatterPipeline.ts b/src/core/pipeline/FormatterPipeline.ts index cff1b5c..5864e8c 100644 --- a/src/core/pipeline/FormatterPipeline.ts +++ b/src/core/pipeline/FormatterPipeline.ts @@ -7,13 +7,12 @@ import * as fs from "fs/promises"; import * as path from "path"; import { CoreConfig, FormatterOrder } from "../config"; import { Container } from "../di"; -import { BlankLineBeforeReturnsRule, BlankLineBetweenDeclarationsRule, BlankLineBetweenStatementTypesRule, BlockSpacingRule, BracketSpacingRule, ClassMemberSortingRule, DocBlockCommentRule, FileDeclarationSortingRule, IFormattingRule, ImportOrganizationRule, IndentationRule, IndexGenerationRule, QuoteStyleRule, SemicolonRule } from "../formatters"; +import { BlankLineBeforeReturnsRule, BlankLineBetweenDeclarationsRule, BlankLineBetweenStatementTypesRule, BlockSpacingRule, BracketSpacingRule, ClassMemberSortingRule, DocBlockCommentRule, FileDeclarationSortingRule, IFormattingRule, ImportOrganizationRule, IndentationRule, IndexGenerationRule, QuoteStyleRule, SemicolonRule, StructuralIndentationRule } from "../formatters"; /* * Tracks the state of a single formatter execution */ - export interface FormatterExecution { formatterName: string; order: FormatterOrder; @@ -22,7 +21,6 @@ export interface FormatterExecution { } /** Context object tracking the entire pipeline execution */ - export interface PipelineContext { filePath: string; originalSource: string; @@ -33,7 +31,6 @@ export interface PipelineContext { } /** Error thrown when a formatter fails during pipeline execution */ - export class FormatterError extends Error { constructor(public readonly formatterName: string, public readonly filePath: string, public readonly originalError: Error) { super(`Formatter '${formatterName}' failed for file '${filePath}': ${originalError.message}`); @@ -45,7 +42,6 @@ export class FormatterError extends Error { * Orchestrates the execution of multiple formatters in a defined order. * Implements fail-fast error handling and supports dry-run mode. */ - export class FormatterPipeline { private formatterOrder: FormatterOrder[]; @@ -79,7 +75,7 @@ export class FormatterPipeline { if (line.includes("FormatterPipeline.extractTypeNameFromStack") || line.includes("FormatterPipeline.addRule")) { continue; - } + } // Find the first external call location const match = line.match(/at\s+.*\s+\((.+):(\d+):(\d+)\)/); @@ -164,6 +160,20 @@ export class FormatterPipeline { return files; } + /** Check if source code contains a tsfmt-ignore directive */ + private shouldIgnoreFile(source: string): boolean { + // Check the first 1000 characters for the ignore directive + // This covers file headers, copyright notices, and initial comments + const header = source.slice(0, 1000); + + // Match tsfmt-ignore in various comment formats: + // - // tsfmt-ignore (single-line comment) + // - /* tsfmt-ignore */ (inline block comment) + // - * tsfmt-ignore (inside multi-line block comment) + // - tsfmt-ignore on its own line in a block comment + return /(?:\/\/|\/\*|\*)\s*tsfmt-ignore|^\s*tsfmt-ignore\s*$/m.test(header); + } + /** * Format a file using the configured formatters in sequence * @param filePath - Absolute path to the file to format @@ -173,9 +183,20 @@ export class FormatterPipeline { */ async formatFile(filePath: string, dryRun = false): Promise { // Read original source - const originalSource = await fs.readFile(filePath, "utf-8"); + // Check for tsfmt-ignore directive + if (this.shouldIgnoreFile(originalSource)) { + return { + filePath, + originalSource, + currentSource: originalSource, + executions: [], + changed: false, + dryRun, + }; + } + // Initialize pipeline context const context: PipelineContext = { filePath, @@ -184,10 +205,9 @@ export class FormatterPipeline { executions: [], changed: false, dryRun, -}; + }; // Execute rules in order - for (const order of this.formatterOrder) { const rulesAtOrder = this.rules.get(order); @@ -200,16 +220,14 @@ export class FormatterPipeline { formatterName: rule.name, order, changed: false, -}; + }; try { // Execute rule - const beforeSource = context.currentSource; const afterSource = rule.apply(context.currentSource, filePath); // Track changes - execution.changed = beforeSource !== afterSource; context.currentSource = afterSource; @@ -228,7 +246,6 @@ export class FormatterPipeline { } // Write to disk if changes were made and not in dry-run mode - if (context.changed && !dryRun) { await fs.writeFile(filePath, context.currentSource, "utf-8"); } @@ -296,6 +313,7 @@ export class FormatterPipeline { this.addRule(FormatterOrder.CodeStyle); this.addRule(FormatterOrder.CodeStyle); this.addRule(FormatterOrder.CodeStyle); + this.addRule(FormatterOrder.CodeStyle); this.addRule(FormatterOrder.CodeStyle); this.addRule(FormatterOrder.CodeStyle); } diff --git a/src/core/pipeline/__tests__/FormatterPipeline.test.ts b/src/core/pipeline/__tests__/FormatterPipeline.test.ts index 2618842..35d0105 100644 --- a/src/core/pipeline/__tests__/FormatterPipeline.test.ts +++ b/src/core/pipeline/__tests__/FormatterPipeline.test.ts @@ -30,7 +30,7 @@ describe("FormatterPipeline", () => { ...ConfigDefaults.getDefaultConfig(), codeStyle: {enabled: true, quoteStyle: "double"}, -}; + }; const container = new Container(); ServiceRegistration.registerServices(container, config); @@ -50,7 +50,7 @@ describe("FormatterPipeline", () => { ...ConfigDefaults.getDefaultConfig(), formatterOrder: [FormatterOrder.Spacing, FormatterOrder.CodeStyle], -}; + }; const container = new Container(); ServiceRegistration.registerServices(container, config); @@ -68,7 +68,7 @@ describe("FormatterPipeline", () => { sorting: {enabled: false}, imports: {enabled: false}, spacing: {enabled: false}, -}; + }; const container = new Container(); ServiceRegistration.registerServices(container, config); @@ -87,7 +87,7 @@ describe("FormatterPipeline", () => { sorting: {enabled: false}, codeStyle: {enabled: false}, spacing: {enabled: false}, -}; + }; const container = new Container(); ServiceRegistration.registerServices(container, config); @@ -106,7 +106,7 @@ describe("FormatterPipeline", () => { imports: {enabled: false}, sorting: {enabled: false}, spacing: {enabled: false}, -}; + }; const container = new Container(); ServiceRegistration.registerServices(container, config); @@ -129,7 +129,7 @@ describe("FormatterPipeline", () => { imports: {enabled: false}, // Disable imports to test only code style sorting: {enabled: false}, // Disable sorting for this test spacing: {enabled: false}, // Disable spacing for this test -}; + }; const container = new Container(); ServiceRegistration.registerServices(container, config); @@ -161,7 +161,7 @@ describe("FormatterPipeline", () => { sorting: {enabled: false}, // Disable sorting for this test imports: {enabled: false}, // Disable imports for this test spacing: {enabled: false}, // Disable spacing for this test -}; + }; const container = new Container(); ServiceRegistration.registerServices(container, config); @@ -189,7 +189,7 @@ describe("FormatterPipeline", () => { imports: {enabled: true, sortImports: true}, sorting: {enabled: false}, // Disable sorting for this test spacing: {enabled: false}, // Disable spacing for this test -}; + }; const container = new Container(); ServiceRegistration.registerServices(container, config); @@ -217,7 +217,7 @@ describe("FormatterPipeline", () => { sorting: {enabled: false}, // Disable sorting for this test imports: {enabled: false}, // Disable imports for this test spacing: {enabled: false}, // Disable spacing for this test -}; + }; const container = new Container(); ServiceRegistration.registerServices(container, config); @@ -245,7 +245,7 @@ describe("FormatterPipeline", () => { sorting: {enabled: false}, // Disable sorting for this test imports: {enabled: false}, // Disable imports for this test spacing: {enabled: false}, // Disable spacing for this test -}; + }; const container = new Container(); ServiceRegistration.registerServices(container, config); @@ -255,6 +255,82 @@ describe("FormatterPipeline", () => { expect(context.changed).toBe(false); expect(context.executions[0].changed).toBe(false); }); + it("should skip formatting files with // tsfmt-ignore comment", async () => { + const source = "// tsfmt-ignore\nconst foo = 'single quotes';"; + + await fs.writeFile(testFilePath, source, "utf-8"); + + const config: CoreConfig = { + ...ConfigDefaults.getDefaultConfig(), + indexGeneration: {enabled: false}, + codeStyle: {enabled: true, quoteStyle: "double"}, + sorting: {enabled: false}, + imports: {enabled: false}, + spacing: {enabled: false}, + }; + + const container = new Container(); + ServiceRegistration.registerServices(container, config); + const pipeline = new FormatterPipeline(config, container); + const context = await pipeline.formatFile(testFilePath, false); + + expect(context.changed).toBe(false); + expect(context.executions).toHaveLength(0); + expect(context.currentSource).toBe(source); + + const fileContent = await fs.readFile(testFilePath, "utf-8"); + expect(fileContent).toBe(source); + }); + it("should skip formatting files with /* tsfmt-ignore */ comment", async () => { + const source = "/* tsfmt-ignore */\nconst foo = 'single quotes';"; + + await fs.writeFile(testFilePath, source, "utf-8"); + + const config: CoreConfig = { + ...ConfigDefaults.getDefaultConfig(), + indexGeneration: {enabled: false}, + codeStyle: {enabled: true, quoteStyle: "double"}, + sorting: {enabled: false}, + imports: {enabled: false}, + spacing: {enabled: false}, + }; + + const container = new Container(); + ServiceRegistration.registerServices(container, config); + const pipeline = new FormatterPipeline(config, container); + const context = await pipeline.formatFile(testFilePath, false); + + expect(context.changed).toBe(false); + expect(context.executions).toHaveLength(0); + }); + it("should skip formatting files with tsfmt-ignore in header comment", async () => { + const source = [ + "/*", + "* Copyright notice", + "* tsfmt-ignore", + "*/", + "const foo = 'single quotes';" + ].join("\n"); + + await fs.writeFile(testFilePath, source, "utf-8"); + + const config: CoreConfig = { + ...ConfigDefaults.getDefaultConfig(), + indexGeneration: {enabled: false}, + codeStyle: {enabled: true, quoteStyle: "double"}, + sorting: {enabled: false}, + imports: {enabled: false}, + spacing: {enabled: false}, + }; + + const container = new Container(); + ServiceRegistration.registerServices(container, config); + const pipeline = new FormatterPipeline(config, container); + const context = await pipeline.formatFile(testFilePath, false); + + expect(context.changed).toBe(false); + expect(context.executions).toHaveLength(0); + }); }); describe("formatFiles", () => { it("should format multiple files", async () => { @@ -272,7 +348,7 @@ describe("FormatterPipeline", () => { sorting: {enabled: false}, // Disable sorting for this test imports: {enabled: false}, // Disable imports for this test spacing: {enabled: false}, // Disable spacing for this test -}; + }; const container = new Container(); ServiceRegistration.registerServices(container, config); @@ -304,7 +380,7 @@ describe("FormatterPipeline", () => { sorting: {enabled: false}, // Disable sorting for this test imports: {enabled: false}, // Disable imports for this test spacing: {enabled: false}, // Disable spacing for this test -}; + }; const container = new Container(); ServiceRegistration.registerServices(container, config); @@ -325,7 +401,7 @@ describe("FormatterPipeline", () => { indexGeneration: {enabled: false}, codeStyle: {enabled: true, quoteStyle: "double"}, spacing: {enabled: false}, -}; + }; const container = new Container(); ServiceRegistration.registerServices(container, config); @@ -346,7 +422,7 @@ describe("FormatterPipeline", () => { sorting: {enabled: false}, // Disable sorting for this test imports: {enabled: false}, // Disable imports for this test spacing: {enabled: false}, // Disable spacing for this test -}; + }; const container = new Container(); ServiceRegistration.registerServices(container, config); diff --git a/src/sortPackage.ts b/src/sortPackage.ts index 734932a..6346716 100644 --- a/src/sortPackage.ts +++ b/src/sortPackage.ts @@ -16,7 +16,7 @@ export function sortPackageJson(packageObj: Record, options: SortOp let sortedPackage = baseSortPackageJson(packageObj, { sortOrder, -}); + }); if (sortedPackage.exports) { sortedPackage.exports = sortExportsKeys(sortedPackage.exports); diff --git a/vite.config.ts b/vite.config.ts index f3e6fbe..9ca695d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,10 +13,10 @@ export default defineConfig({ index: resolve(__dirname, "src/index.ts"), "sortPackage": resolve(__dirname, "src/sortPackage.ts"), "sortTSConfig": resolve(__dirname, "src/sortTSConfig.ts"), -}, + }, formats: ["cjs"], fileName: (format, entryName) => `${entryName}.js`, -}, + }, rollupOptions: { external: [ "fs", @@ -34,12 +34,12 @@ export default defineConfig({ preserveModulesRoot: "src", entryFileNames: "[name].js", format: "cjs" -}, -}, + }, + }, minify: false, -}, + }, plugins: [transformGenericsPlugin()], esbuild: { target: "node22", -}, + }, }); \ No newline at end of file