Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import 'package:solid_lints/src/lints/avoid_unnecessary_type_assertions/fixes/av
import 'package:solid_lints/src/lints/avoid_unused_parameters/avoid_unused_parameters_rule.dart';
import 'package:solid_lints/src/lints/double_literal_format/double_literal_format_rule.dart';
import 'package:solid_lints/src/lints/double_literal_format/fixes/double_literal_format_fix.dart';
import 'package:solid_lints/src/lints/named_parameters_ordering/fixes/named_parameters_ordering_fix.dart';
import 'package:solid_lints/src/lints/named_parameters_ordering/named_parameters_ordering_rule.dart';
import 'package:solid_lints/src/lints/proper_super_calls/proper_super_calls_rule.dart';
import 'package:solid_lints/src/lints/use_nearest_context/fixes/rename_nearest_context_parameter_fix.dart';
import 'package:solid_lints/src/lints/use_nearest_context/fixes/replace_with_nearest_context_parameter_fix.dart';
Expand Down Expand Up @@ -53,6 +55,9 @@ class SolidLintsPlugin extends Plugin {
AvoidUnusedParametersRule(
analysisOptionsLoader: analysisLoader,
),
NamedParametersOrderingRule(
analysisOptionsLoader: analysisLoader,
),
UseNearestContextRule(),
];

Expand Down Expand Up @@ -83,5 +88,10 @@ class SolidLintsPlugin extends Plugin {
UseNearestContextRule.code,
ReplaceWithNearestContextParameterFix.new,
);

registry.registerFixForRule(
NamedParametersOrderingRule.code,
NamedParametersOrderingFix.new,
);
}
}
15 changes: 4 additions & 11 deletions lib/src/lints/named_parameters_ordering/config_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,13 @@ import 'package:solid_lints/src/lints/named_parameters_ordering/models/parameter

/// Helper class to parse member_ordering rule config
class NamedParametersConfigParser {
static const _defaultOrderList = [
'required_super',
'super',
'required',
'nullable',
'default',
];

/// Parse rule config for regular class order rules
static List<ParameterType> parseOrder(Object? orderConfig) {
final order = orderConfig is Iterable
? List<String>.from(orderConfig)
: _defaultOrderList;
if (orderConfig is! Iterable) {
return ParameterType.defaultOrder;
}

final order = List<String>.from(orderConfig);
return order.map(ParameterType.fromType).nonNulls.toList();
}
Comment on lines 29 to 36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Missing Parameter Types Bug in Custom Configuration

If a user configures a custom order in analysis_options.yaml but omits some of the parameter types (e.g., only specifying required and nullable), the omitted types will not be present in the parsed _parametersOrder list.

As a result, _parametersOrder.indexOf(type) will return -1 for those omitted types. This causes severe bugs:

  1. In _isOrderingWrong, -1 is compared with other indices, leading to incorrect lint reports (e.g., reporting that a default parameter should be before a required parameter because -1 < 0).
  2. In the quick fix, sorting will place omitted types at the very beginning of the parameter list.

To fix this, we should append any missing parameter types (from ParameterType.defaultOrder) to the end of the parsed list so that every ParameterType has a valid, non-negative index.

  static List<ParameterType> parseOrder(Object? orderConfig) {
    if (orderConfig is! Iterable) {
      return ParameterType.defaultOrder;
    }

    final order = List<String>.from(orderConfig);
    final parsed = order.map(ParameterType.fromType).nonNulls.toList();
    final missing = ParameterType.defaultOrder.where((type) => !parsed.contains(type));
    return [...parsed, ...missing];
  }

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import 'package:analysis_server_plugin/edit/dart/correction_producer.dart';
import 'package:analysis_server_plugin/edit/dart/dart_fix_kind_priority.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/source/source_range.dart';
import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart';
import 'package:analyzer_plugin/utilities/fixes/fixes.dart';
import 'package:solid_lints/src/lints/named_parameters_ordering/models/parameter_type.dart';
import 'package:solid_lints/src/lints/named_parameters_ordering/named_parameters_ordering_rule.dart';
import 'package:yaml/yaml.dart';
Comment on lines +7 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import NamedParametersConfigParser to reuse the configuration parsing logic and ensure consistent behavior between the lint rule and the quick fix.

Suggested change
import 'package:solid_lints/src/lints/named_parameters_ordering/models/parameter_type.dart';
import 'package:solid_lints/src/lints/named_parameters_ordering/named_parameters_ordering_rule.dart';
import 'package:yaml/yaml.dart';
import 'package:solid_lints/src/lints/named_parameters_ordering/config_parser.dart';
import 'package:solid_lints/src/lints/named_parameters_ordering/models/parameter_type.dart';
import 'package:solid_lints/src/lints/named_parameters_ordering/named_parameters_ordering_rule.dart';
import 'package:yaml/yaml.dart';


/// A Quick fix for [NamedParametersOrderingRule] rule.
class NamedParametersOrderingFix extends ResolvedCorrectionProducer {
static const _fixKind = FixKind(
'solid_lints.fix.named_parameters_ordering',
DartFixKindPriority.standard,
"Sort named parameters",
);

/// Creates a new instance of [NamedParametersOrderingFix].
NamedParametersOrderingFix({required super.context});

@override
FixKind get fixKind => _fixKind;

@override
CorrectionApplicability get applicability =>
CorrectionApplicability.automatically;

@override
Future<void> compute(ChangeBuilder builder) async {
final parameterList = node.thisOrAncestorOfType<FormalParameterList>();
if (parameterList == null) return;

final namedParams = parameterList.parameters
.where((p) => p.isNamed)
.toList();
if (namedParams.length < 2) return;

final parametersOrder = _getParametersOrder();

final sortedNamedParams = [...namedParams];
sortedNamedParams.sort((a, b) {
final typeA = ParameterType.fromParameter(a);
final typeB = ParameterType.fromParameter(b);
final indexA = parametersOrder.indexOf(typeA);
final indexB = parametersOrder.indexOf(typeB);
return indexA.compareTo(indexB);
});
Comment on lines +42 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When sorting the named parameters, if a parameter type is not present in the configured parametersOrder list, indexOf will return -1. This causes unlisted parameter types to be sorted to the very beginning of the list, which is counter-intuitive and contradicts the expected behavior (where unlisted types should ideally be placed at the end).

To fix this, we should treat -1 as a large index (e.g., parametersOrder.length) so that unlisted parameters are consistently sorted to the end.

Suggested change
sortedNamedParams.sort((a, b) {
final typeA = ParameterType.fromParameter(a);
final typeB = ParameterType.fromParameter(b);
final indexA = parametersOrder.indexOf(typeA);
final indexB = parametersOrder.indexOf(typeB);
return indexA.compareTo(indexB);
});
sortedNamedParams.sort((a, b) {
final typeA = ParameterType.fromParameter(a);
final typeB = ParameterType.fromParameter(b);
var indexA = parametersOrder.indexOf(typeA);
var indexB = parametersOrder.indexOf(typeB);
if (indexA == -1) indexA = parametersOrder.length;
if (indexB == -1) indexB = parametersOrder.length;
return indexA.compareTo(indexB);
});


// Check if the order is already correct (if sorting changed nothing)
bool isChanged = false;
for (int i = 0; i < namedParams.length; i++) {
if (namedParams[i] != sortedNamedParams[i]) {
isChanged = true;
break;
}
}
if (!isChanged) return;

final isMultiline = utils
.getRangeText(parameterList.sourceRange)
.contains('\n');

if (!isMultiline) {
// Single-line: no leading comments, simple text replacement
final sortedTexts = sortedNamedParams
.map((p) => utils.getRangeText(p.sourceRange))
.toList();

final replacementText = sortedTexts.join(', ');

final targetRange = SourceRange(
namedParams.first.offset,
namedParams.last.end - namedParams.first.offset,
);

await builder.addDartFileEdit(file, (builder) {
builder.addSimpleReplacement(targetRange, replacementText);
});
return;
}

// Multiline: extract parameter blocks including leading comments
final (:blockTexts, :firstBlockStart) = _extractParamBlocks(
namedParams,
parameterList,
);

// Map sorted parameters to their corresponding block texts
final sortedBlockTexts = sortedNamedParams
.map((p) => blockTexts[namedParams.indexOf(p)])
.toList();

final replacementText = sortedBlockTexts.join(',\n');

final targetRange = SourceRange(
firstBlockStart,
namedParams.last.end - firstBlockStart,
);

await builder.addDartFileEdit(file, (builder) {
builder.addSimpleReplacement(targetRange, replacementText);
});
}

/// Extracts text blocks for each named parameter, including any leading
/// comments that belong to that parameter.
///
/// Returns the block texts and the start offset of the first block
/// (used as the replacement range start).
({List<String> blockTexts, int firstBlockStart}) _extractParamBlocks(
List<FormalParameter> namedParams,
FormalParameterList parameterList,
) {
final blocks = <String>[];
int? firstStart;

for (int i = 0; i < namedParams.length; i++) {
final param = namedParams[i];

final int minOffset = i == 0
? (parameterList.leftDelimiter?.end ??
parameterList.leftParenthesis.end)
: namedParams[i - 1].end;

// Find block start: use leading comment offset if present,
// then expand to line start for proper indentation
var blockStart = param.offset;
final comment = param.beginToken.precedingComments;
if (comment != null &&
comment.offset >= minOffset &&
comment.offset < param.offset) {
blockStart = comment.offset;
}
final linePrefix = utils.getLinePrefix(blockStart);
blockStart -= linePrefix.length;
Comment on lines +121 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a potential bug when extracting parameter blocks with comments.

If a comment is a trailing comment on the same line as the previous parameter (e.g., required super.accountType, // comment), it will be treated as a preceding comment of the current parameter (this.age) because there are no other tokens between them.

This causes blockStart for the current parameter to be set to the comment's offset on the previous line, resulting in overlapping blocks and corrupted code replacement.

To prevent this, we should only associate a preceding comment with the current parameter if there is a newline between the end of the previous parameter and the start of the comment. Additionally, we can use utils.getLineStart(blockStart) to safely and cleanly find the start of the line instead of manual subtraction.

Suggested change
final int minOffset = i == 0
? (parameterList.leftDelimiter?.end ??
parameterList.leftParenthesis.end)
: namedParams[i - 1].end;
// Find block start: use leading comment offset if present,
// then expand to line start for proper indentation
var blockStart = param.offset;
final comment = param.beginToken.precedingComments;
if (comment != null &&
comment.offset >= minOffset &&
comment.offset < param.offset) {
blockStart = comment.offset;
}
final linePrefix = utils.getLinePrefix(blockStart);
blockStart -= linePrefix.length;
final int minOffset = i == 0
? (parameterList.leftDelimiter?.end ??
parameterList.leftParenthesis.end)
: namedParams[i - 1].end;
// Find block start: use leading comment offset if present,
// then expand to line start for proper indentation
var blockStart = param.offset;
final comment = param.beginToken.precedingComments;
if (comment != null &&
comment.offset >= minOffset &&
comment.offset < param.offset) {
final textBetween = utils.getRangeText(SourceRange(minOffset, comment.offset - minOffset));
if (textBetween.contains('\\n')) {
blockStart = comment.offset;
}
}
blockStart = utils.getLineStart(blockStart);


firstStart ??= blockStart;
blocks.add(
utils.getRangeText(SourceRange(blockStart, param.end - blockStart)),
);
}

return (
blockTexts: blocks,
firstBlockStart: firstStart ?? namedParams.first.offset,
);
}
Comment on lines +111 to +148

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Trailing Comments Deletion Bug

When extracting parameter blocks in _extractParamBlocks, the block end is determined by param.end. However, any trailing comments on the same line (e.g., required super.accountType, // some comment) occur after param.end but before the next parameter's start.

Because the quick fix replaces the entire range from firstBlockStart to namedParams.last.end, any trailing comments on the same line as a parameter will be completely deleted when the fix is applied.

To fix this, you should either:

  1. Detect if there are any trailing comments on the same line as any of the named parameters and abort the quick fix if so.
  2. Or, adjust the block extraction logic to find the end of the line (or the start of the next parameter's leading comments/whitespace) and handle the commas carefully to preserve trailing comments.


List<ParameterType> _getParametersOrder() {
final pathContext = resourceProvider.pathContext;
String currentDirectoryPath = pathContext.dirname(file);

while (pathContext.dirname(currentDirectoryPath) != currentDirectoryPath) {
final candidatePath = pathContext.join(
currentDirectoryPath,
'analysis_options.yaml',
);
final candidateFile = resourceProvider.getFile(candidatePath);

if (candidateFile.exists) {
try {
final content = candidateFile.readAsStringSync();
final yaml = loadYaml(content);
if (yaml case {
'plugins': {
'solid_lints': {
'diagnostics': {
NamedParametersOrderingRule.lintName: {
'order': final List<Object?> orderList,
},
},
},
},
}) {
final order = orderList
.map((e) => e is String ? ParameterType.fromType(e) : null)
.whereType<ParameterType>()
.toList();
if (order.isNotEmpty) {
return order;
}
Comment on lines +176 to +182

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Reuse NamedParametersConfigParser.parseOrder to parse the custom order list. This avoids duplicating the parsing logic and ensures that any omitted parameter types are correctly appended to the end of the list.

Suggested change
final order = orderList
.map((e) => e is String ? ParameterType.fromType(e) : null)
.whereType<ParameterType>()
.toList();
if (order.isNotEmpty) {
return order;
}
final order = NamedParametersConfigParser.parseOrder(orderList);
if (order.isNotEmpty) {
return order;
}

}
} catch (_) {
// ignore parsing error, fallback to default order
}
break;
}

final parentDir = pathContext.dirname(currentDirectoryPath);
currentDirectoryPath = parentDir;
}

return ParameterType.defaultOrder;
}
}
43 changes: 43 additions & 0 deletions lib/src/lints/named_parameters_ordering/models/parameter_type.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:analyzer/dart/ast/ast.dart';
import 'package:collection/collection.dart';

/// Represents a function parameter type
Expand All @@ -17,11 +18,53 @@ enum ParameterType {
/// Default value parameter type (String parameterName = 'defaultValue')
defaultValue('default');

/// The default ordering of parameter types.
static const defaultOrder = [
ParameterType.requiredInherited,
ParameterType.inherited,
ParameterType.required,
ParameterType.nullable,
ParameterType.defaultValue,
];

/// Returns [ParameterType] from type or null if not found
static ParameterType? fromType(String type) {
return values.firstWhereOrNull((o) => o.type == type);
}

/// Classifies a [FormalParameter] into a [ParameterType].
///
/// Recursively unwraps [DefaultFormalParameter] wrappers to determine
/// the underlying parameter kind.
static ParameterType fromParameter(
FormalParameter parameter, {
bool hasDefaultValue = false,
}) {
if (parameter is DefaultFormalParameter &&
parameter.parameter is! DefaultFormalParameter) {
return fromParameter(
parameter.parameter,
hasDefaultValue: parameter.defaultValue != null,
);
}

switch (parameter) {
case SuperFormalParameter(:final isRequired):
return isRequired
? ParameterType.requiredInherited
: ParameterType.inherited;

case DefaultFormalParameter():
case _ when hasDefaultValue:
return ParameterType.defaultValue;
Comment on lines +57 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Unreachable Case in Switch Statement

The case DefaultFormalParameter(): is unreachable because any DefaultFormalParameter is already unwrapped and handled recursively by the if statement at the beginning of the method:

if (parameter is DefaultFormalParameter && ...)

You can safely remove this case to keep the switch statement clean and maintainable.

      case _ when hasDefaultValue:
        return ParameterType.defaultValue;


case FieldFormalParameter(:final isRequired) ||
FunctionTypedFormalParameter(:final isRequired) ||
SimpleFormalParameter(:final isRequired):
return isRequired ? ParameterType.required : ParameterType.nullable;
}
}

/// String representation of the parameter type
final String type;

Expand Down
Loading