Skip to content

Commit 41a72e6

Browse files
committed
feat: add allowNamedFunctions: 'only-expressions' option
Closes #23
1 parent f941a81 commit 41a72e6

File tree

5 files changed

+178
-4
lines changed

5 files changed

+178
-4
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ An optional array of function names to ignore. When set, the rule won't report n
4040

4141
### `allowNamedFunctions`
4242

43-
If set to true, the rule won't report named functions such as `function foo() {}`. Anonymous function such as `const foo = function() {}` will still be reported.
43+
Controls how named functions are handled:
44+
45+
- When `true`, the rule won't report any named functions such as `function foo() {}`. Anonymous functions such as `const foo = function() {}` will still be reported.
46+
- When `"only-expressions"`, the rule will allow named function expressions (like `callback(function namedFn() {})`) but will still transform named function declarations (like `function foo() {}`).
47+
- When `false` (default), all functions will be transformed to arrow functions when safe to do so.
4448

4549
### `allowObjectProperties`
4650

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export interface Scope {
1818

1919
export interface ActualOptions {
2020
allowedNames: string[];
21-
allowNamedFunctions: boolean;
21+
allowNamedFunctions: boolean | 'only-expressions';
2222
allowObjectProperties: boolean;
2323
classPropertiesAllowed: boolean;
2424
disallowPrototype: boolean;

src/guard.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,14 @@ export class Guard {
212212
return fn.id !== null && fn.id.name !== null;
213213
}
214214

215+
isNamedFunctionExpression(fn: AnyFunction): fn is NamedFunction & TSESTree.FunctionExpression {
216+
return fn.type === AST_NODE_TYPES.FunctionExpression && this.isNamedFunction(fn);
217+
}
218+
219+
isNamedFunctionDeclaration(fn: AnyFunction): fn is NamedFunction & TSESTree.FunctionDeclaration {
220+
return fn.type === AST_NODE_TYPES.FunctionDeclaration && this.isNamedFunction(fn);
221+
}
222+
215223
hasNameAndIsExportedAsDefaultExport(fn: AnyFunction): fn is NamedFunction {
216224
return this.isNamedFunction(fn) && fn.parent.type === AST_NODE_TYPES.ExportDefaultDeclaration;
217225
}
@@ -241,7 +249,8 @@ export class Guard {
241249
!this.containsNewDotTarget(fn);
242250
if (!isSafe) return false;
243251
if (this.isIgnored(fn)) return false;
244-
if (this.options.allowNamedFunctions && this.isNamedFunction(fn)) return false;
252+
if (this.options.allowNamedFunctions === true && this.isNamedFunction(fn)) return false;
253+
if (this.options.allowNamedFunctions === 'only-expressions' && this.isNamedFunctionExpression(fn)) return false;
245254
if (!this.options.disallowPrototype && this.isPrototypeAssignment(fn)) return false;
246255
if (this.options.singleReturnOnly && !this.returnsImmediately(fn)) return false;
247256
if (this.isObjectProperty(fn) && this.options.allowObjectProperties) return false;

src/rule.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const preferArrowFunctions = createRule<Options, MessageId>({
2828
},
2929
allowNamedFunctions: {
3030
default: DEFAULT_OPTIONS.allowNamedFunctions,
31-
type: 'boolean',
31+
oneOf: [{ type: 'boolean' }, { type: 'string', enum: ['only-expressions'] }],
3232
},
3333
allowObjectProperties: {
3434
default: DEFAULT_OPTIONS.allowObjectProperties,

test/rule.spec.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,167 @@ describe('when allowNamedFunctions is true', () => {
483483
});
484484
});
485485

486+
describe('when allowNamedFunctions is "only-expressions"', () => {
487+
describe('it allows named function expressions but transforms named declarations and anonymous expressions', () => {
488+
ruleTester.run('prefer-arrow-functions', rule, {
489+
valid: [
490+
// Named function expressions should be preserved
491+
{ code: 'useThisFunction(function doSomething() { return "bar"; })' },
492+
{ code: 'useThisFunction(function namedFunc() { console.log("test"); })' },
493+
{ code: 'callback(function myCallback() { return 42; })' },
494+
{ code: 'React.forwardRef(function MyComponent(props, ref) { return props.children; })' },
495+
{ code: 'addEventListener("click", function handleClick(event) { console.log(event); })' },
496+
{ code: 'arr.map(function mapFn(item) { return item * 2; })' },
497+
{ code: 'setTimeout(function delayedFn() { console.log("delayed"); }, 1000)' },
498+
499+
// Named function expressions with async should be preserved
500+
{ code: 'useAsyncFunction(async function asyncNamed() { return await getData(); })' },
501+
502+
// Named function expressions with generators should be preserved
503+
{ code: 'useGenerator(function* generatorNamed() { yield 1; })' },
504+
505+
// Named function expressions that preserve this behavior
506+
{ code: 'obj.method(function namedWithThis() { return this.value; })' },
507+
{ code: 'context.bind(function boundNamed() { this.prop = "value"; })' },
508+
509+
// Named function expressions with complex parameters
510+
{ code: 'processor(function processData(data, { options = {} } = {}) { return data.process(options); })' },
511+
512+
// Nested named function expressions
513+
{ code: 'outer(function outerNamed() { return inner(function innerNamed() { return "nested"; }); })' },
514+
515+
// Arrow functions should remain valid (no change from existing behavior)
516+
{ code: 'const arrow = () => "arrow"' },
517+
{ code: 'useArrowFunction(() => "test")' },
518+
].map(withOptions({ allowNamedFunctions: 'only-expressions' })),
519+
invalid: [
520+
// Named function declarations should be transformed
521+
{
522+
code: 'function doSomething() { return "bar"; }',
523+
output: 'const doSomething = () => "bar";',
524+
},
525+
{
526+
code: 'function namedDeclaration() { console.log("test"); }',
527+
output: 'const namedDeclaration = () => { console.log("test"); };',
528+
},
529+
{
530+
code: 'async function asyncDeclaration() { return await getData(); }',
531+
output: 'const asyncDeclaration = async () => await getData();',
532+
},
533+
534+
// Anonymous function expressions should be transformed
535+
{
536+
code: 'useThisFunction(function() { return "bar"; })',
537+
output: 'useThisFunction(() => "bar")',
538+
},
539+
{
540+
code: 'callback(function() { console.log("test"); })',
541+
output: 'callback(() => { console.log("test"); })',
542+
},
543+
{
544+
code: 'addEventListener("click", function(event) { console.log(event); })',
545+
output: 'addEventListener("click", (event) => { console.log(event); })',
546+
},
547+
{
548+
code: 'arr.map(function(item) { return item * 2; })',
549+
output: 'arr.map((item) => item * 2)',
550+
},
551+
{
552+
code: 'setTimeout(function() { console.log("delayed"); }, 1000)',
553+
output: 'setTimeout(() => { console.log("delayed"); }, 1000)',
554+
},
555+
556+
// Anonymous async function expressions should be transformed
557+
{
558+
code: 'useAsyncFunction(async function() { return await getData(); })',
559+
output: 'useAsyncFunction(async () => await getData())',
560+
},
561+
562+
// Variable assignments with anonymous functions should be transformed
563+
{
564+
code: 'var foo = function() { return "bar"; };',
565+
output: 'var foo = () => "bar";',
566+
},
567+
{
568+
code: 'const handler = function() { console.log("click"); };',
569+
output: 'const handler = () => { console.log("click"); };',
570+
},
571+
572+
// Object methods with anonymous functions should be transformed
573+
{
574+
code: 'var obj = { method: function() { return "test"; } }',
575+
output: 'var obj = { method: () => "test" }',
576+
},
577+
578+
// Nested cases: outer anonymous should transform, inner named expression should not
579+
{
580+
code: 'var outer = function() { return inner(function namedInner() { return "nested"; }); };',
581+
output: 'var outer = () => inner(function namedInner() { return "nested"; });',
582+
},
583+
584+
// Export default declarations should be transformed
585+
{
586+
code: 'export default function() { return "exported"; }',
587+
output: 'export default () => "exported";',
588+
},
589+
]
590+
.map(withOptions({ allowNamedFunctions: 'only-expressions' }))
591+
.map(withErrors(['USE_ARROW_WHEN_FUNCTION']))
592+
.concat([
593+
// Multiple function scenarios - both should be transformed (handled separately)
594+
{
595+
code: 'function declaration() { return "decl"; } var expr = function() { return "expr"; };',
596+
output: 'const declaration = () => "decl"; var expr = () => "expr";',
597+
options: [{ allowNamedFunctions: 'only-expressions' }],
598+
errors: [{ messageId: 'USE_ARROW_WHEN_FUNCTION' }, { messageId: 'USE_ARROW_WHEN_FUNCTION' }],
599+
},
600+
]),
601+
});
602+
});
603+
604+
describe('it preserves this behavior with only-expressions option', () => {
605+
ruleTester.run('prefer-arrow-functions', rule, {
606+
valid: [
607+
// Named function expressions that use this should be preserved
608+
{ code: 'obj.addEventListener("click", function handleClick() { this.style.color = "red"; })' },
609+
{ code: 'elements.forEach(function processElement() { this.process(); })' },
610+
{ code: 'jQuery(selector).each(function namedEach() { $(this).addClass("active"); })' },
611+
612+
// Functions that don't use this but are named expressions should be preserved
613+
{ code: 'callback(function namedCallback() { return "no this used"; })' },
614+
615+
// Anonymous functions that use this should be preserved (existing behavior)
616+
{ code: 'obj.addEventListener("click", function() { this.style.color = "red"; })' },
617+
{ code: 'elements.forEach(function() { this.process(); })' },
618+
619+
// Arrow functions should remain valid (no change)
620+
{ code: 'obj.addEventListener("click", () => { console.log("arrow with no this"); })' },
621+
].map(withOptions({ allowNamedFunctions: 'only-expressions' })),
622+
invalid: [
623+
// Named function declarations that don't use this should still be transformed
624+
{
625+
code: 'function noThisUsed() { return "safe to transform"; }',
626+
output: 'const noThisUsed = () => "safe to transform";',
627+
},
628+
629+
// Anonymous function expressions that don't use this should be transformed
630+
{
631+
code: 'callback(function() { return "no this, should transform"; })',
632+
output: 'callback(() => "no this, should transform")',
633+
},
634+
635+
// Variable assignments with anonymous functions that don't use this
636+
{
637+
code: 'var handler = function() { console.log("no this"); };',
638+
output: 'var handler = () => { console.log("no this"); };',
639+
},
640+
]
641+
.map(withOptions({ allowNamedFunctions: 'only-expressions' }))
642+
.map(withErrors(['USE_ARROW_WHEN_FUNCTION'])),
643+
});
644+
});
645+
});
646+
486647
describe('when file is TSX', () => {
487648
describe('it properly fixes generic type arguments', () => {
488649
ruleTester.run('prefer-arrow-functions', rule, {

0 commit comments

Comments
 (0)