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
12 changes: 10 additions & 2 deletions pkgs/swift2objc/lib/src/ast/_core/shared/parameter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,19 @@ class Parameter extends AstNode {
String name;
String? internalName;
ReferredType type;
String? defaultValue;

Parameter({required this.name, this.internalName, required this.type});
Parameter({
required this.name,
this.internalName,
required this.type,
this.defaultValue,
});

@override
String toString() => '$name $internalName: $type';
String toString() =>
'$name $internalName: $type'
'${defaultValue != null ? ' = $defaultValue' : ''}';

@override
void visitChildren(Visitor visitor) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ class AssociatedValueParam extends AstNode implements Parameter {
@override
covariant Null internalName;

@override
String? defaultValue;

AssociatedValueParam({required this.name, required this.type});

@override
Expand Down
67 changes: 52 additions & 15 deletions pkgs/swift2objc/lib/src/parser/_core/token_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,42 +30,79 @@ class TokenList extends Iterable<Json> {
return TokenList._(list, 0, list.length);
}

/// Splits a single token on splittable characters, handling both prefix and
/// suffix cases.
///
/// Swift's symbol graph concatenates some tokens together, for example when
/// a parameter has a default value: " = value) -> returnType" becomes a
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this true? I don't think the returnType can be part of the token. If it can, then just taking splittables of the start and end won't work.

/// single text token. This method splits such tokens by:
/// 1. Repeatedly extracting splittables from the start
/// 2. For the remaining content, pull splittables from the end
/// 3. Store removed suffix tokens and yield them in reverse order
/// 4. Yield any remaining non-splittable content as a single text token
///
/// This approach preserves the correct token sequence and ensures that even
/// complex cases like " = foo) -> " are properly tokenized.
@visibleForTesting
static Iterable<Json> splitToken(Json token) sync* {
const splittables = ['(', ')', '?', ',', '->'];
const splittables = ['(', ')', '?', ',', '->', '='];
Json textToken(String text) =>
Json({'kind': 'text', 'spelling': text}, token.pathSegments);

final text = getSpellingForKind(token, 'text')?.trim();
if (text == null) {
// Not a text token. Pass it though unchanged.
// Not a text token. Pass it through unchanged.
yield token;
return;
}

if (text.isEmpty) {
// Input text token was nothing but whitespace. The loop below would yield
// nothing, but we still need it as a separator.
// Input text token was nothing but whitespace. We still need it as a
// separator for the parser.
yield textToken(text);
return;
}

var suffix = text;
var remaining = text;
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this rename makes this more confusing. You've named this variable remaining, and the other one currentSuffix, but that's kinda the opposite of what they actually represent. We first remove prefixes, so what's left is a suffix. Then we remove suffixes, so what's left isn't really a suffix, and could be better named something generic like remaining. So I'd probably switch these names.

while (true) {
var any = false;
for (final prefix in splittables) {
if (suffix.startsWith(prefix)) {
yield textToken(prefix);
suffix = suffix.substring(prefix.length).trim();
any = true;
var foundPrefix = false;
for (final splittable in splittables) {
if (remaining.startsWith(splittable)) {
yield textToken(splittable);
remaining = remaining.substring(splittable.length).trim();
foundPrefix = true;
break;
}
}
if (!any) {
// Remaining text isn't splittable.
if (suffix.isNotEmpty) yield textToken(suffix);
break;

if (foundPrefix) continue;

// No more prefix splittables found; extract any trailing ones.
Copy link
Contributor

Choose a reason for hiding this comment

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

Move this out into its own while loop below the prefix extractor. Makes it more readable.

// We collect them in a list and yield in reverse order to maintain
// the original token sequence.
final trailingTokens = <String>[];
var currentSuffix = remaining;
while (currentSuffix.isNotEmpty) {
var foundSuffix = false;
for (final splittable in splittables) {
if (currentSuffix.endsWith(splittable)) {
trailingTokens.add(splittable);
currentSuffix = currentSuffix
.substring(0, currentSuffix.length - splittable.length)
.trim();
foundSuffix = true;
break;
}
}
if (!foundSuffix) break;
}

// Yield the core content, then trailing splittables in reverse order.
if (currentSuffix.isNotEmpty) yield textToken(currentSuffix);
for (var i = trailingTokens.length - 1; i >= 0; i--) {
yield textToken(trailingTokens[i]);
}
break;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,91 @@ ParsedFunctionInfo parseFunctionInfo(
final (type, remainingTokens) = parseType(context, symbolgraph, tokens);
tokens = remainingTokens;

// Optional: parse a default argument if present.
// Swift symbol graph can emit defaults in these formats:
// 1. Separate tokens: [": ", type..., " = ", value, ", "]
// 2. Combined: [":", type..., " = value, "] or
// [":", type..., " = value)"]
// 3. With return: [":", type..., " = value) -> "]
//
// We carefully preserve string literals during parsing to avoid
// splitting on delimiters that appear inside quoted strings
// (e.g., the ") -> " in the string literal "World ) -> ").
String? defaultValue;
String? endToken;
var afterType = maybeConsume('text');

if (afterType != null) {
// Check if this text token contains '='
Copy link
Contributor

Choose a reason for hiding this comment

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

A lot of this logic is outdated, now that all the token splitting is done in the token_list. If the token list is working correctly, you shouldn't ever need to further split tokens here, or do any string parsing at all. Each of the parts that you're checking for with these .startsWith and .contains calls should now be a separate token in the list that you receive from token_list.dart, and the default value itself should also be a single token.

It's often hard to modify complex code like this after such a big logic change elsewhere in the system, so I'd approach this by deleting this whole chunk of code, and starting over. Print out the token list that is received here, make sure it makes sense (ie, all tokens are split as you expect, and the default value is its own token), and then write a new parser for that token list. If you find yourself having to use String.split/substring/startsWith/contains in this area, then something is probably wrong in the token list logic.

If things work out like I'm expecting, then this whole chunk of code will be waaaay simpler after that rewrite.

var remaining = afterType.trim();
if (remaining.startsWith('=')) {
// Extract default value from this token
remaining = remaining.substring(1).trim();

// Check for delimiters: ',' or ')' (possibly followed by ' ->')
if (remaining.contains(')')) {
final parenIndex = remaining.indexOf(')');
defaultValue = remaining.substring(0, parenIndex).trim();
endToken = ')';
} else if (remaining.contains(',')) {
final commaIndex = remaining.indexOf(',');
defaultValue = remaining.substring(0, commaIndex).trim();
endToken = ',';
} else if (remaining.isNotEmpty) {
// Default value but no delimiter yet
defaultValue = remaining;
endToken = maybeConsume('text');
} else {
// '=' alone, collect tokens until delimiter
final parts = <String>[];
while (tokens.isNotEmpty) {
final tok = tokens[0];
final kind = tok['kind'].get<String?>();
final spelling = tok['spelling'].get<String?>();
if (spelling != null) {
final s = spelling.trim();
// String tokens should be kept whole, not split on delimiters
if (kind == 'string') {
parts.add(spelling);
tokens = tokens.slice(1);
} else if (s == ',' || s == ')') {
endToken = s;
tokens = tokens.slice(1);
break;
} else if (s.contains(',') || s.contains(')')) {
final delimChar = s.contains(',') ? ',' : ')';
final delimIndex = s.indexOf(delimChar);
parts.add(s.substring(0, delimIndex).trim());
endToken = delimChar;
tokens = tokens.slice(1);
break;
} else {
parts.add(spelling);
tokens = tokens.slice(1);
}
} else {
tokens = tokens.slice(1);
}
}
if (parts.isNotEmpty) {
defaultValue = parts.join('').trim();
}
}
} else {
endToken = afterType;
}
}

parameters.add(
Parameter(name: externalParam, internalName: internalParam, type: type),
Parameter(
name: externalParam,
internalName: internalParam,
type: type,
defaultValue: defaultValue,
),
);

final end = maybeConsume('text');
final end = endToken ?? maybeConsume('text');
if (end == ')') break;
if (end != ',') throw malformedInitializerException;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,28 +81,29 @@ ClassDeclaration transformCompound(
.nonNulls
.toList();

final transformedInitializers = originalCompound.initializers
.map(
(initializer) => transformInitializer(
initializer,
wrappedCompoundInstance,
parentNamer,
state,
),
)
.toList();
final transformedInitializers = <Declaration>[];
Copy link
Contributor

Choose a reason for hiding this comment

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

Dart's fancy new collection literals can simplify this a bit. Something like this should work:

final transformedInitializers = <Declaration>[
  for (final init in originalCompound.initializers)
    ...transformInitializerWithOverloads(
      init,
      wrappedCompoundInstance,
      parentNamer,
      state,
    ),
];

Same below

for (final init in originalCompound.initializers) {
transformedInitializers.addAll(
transformInitializerWithOverloads(
init,
wrappedCompoundInstance,
parentNamer,
state,
),
);
}

final transformedMethods = originalCompound.methods
.map(
(method) => transformMethod(
method,
wrappedCompoundInstance,
parentNamer,
state,
),
)
.nonNulls
.toList();
final transformedMethods = <MethodDeclaration>[];
for (final method in originalCompound.methods) {
transformedMethods.addAll(
transformMethodWithOverloads(
method,
wrappedCompoundInstance,
parentNamer,
state,
),
);
}

transformedCompound.properties =
transformedProperties.whereType<PropertyDeclaration>().toList()
Expand Down
Loading