Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apply DeMorgan's Law #1552

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

AppAppWorks
Copy link
Contributor

fixed #1243

@AppAppWorks AppAppWorks requested a review from ahoppen as a code owner July 3, 2024 22:35
@AppAppWorks AppAppWorks force-pushed the apply-demorgan-law branch from 4c52731 to 18935ab Compare July 3, 2024 22:39
Copy link
Member

@ahoppen ahoppen left a comment

Choose a reason for hiding this comment

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

Thanks for tackling this @AppAppWorks. I have left a first set of review comments inline. Correctness-wise, I think it’s mostly missing the addition of parentheses where precedence of operators flips, eg. !(a && b || c) should get transformed to (!a || !b) && !c.

Comment on lines 547 to 548
case _:
nil
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
case _:
nil
default:
nil

@AppAppWorks AppAppWorks marked this pull request as draft July 4, 2024 09:28
@AppAppWorks
Copy link
Contributor Author

AppAppWorks commented Jul 5, 2024

Thanks for tackling this @AppAppWorks. I have left a first set of review comments inline. Correctness-wise, I think it’s mostly missing the addition of parentheses where precedence of operators flips, eg. !(a && b || c) should get transformed to (!a || !b) && !c.

This example involves recursive DeMorgan's Law applications that have not been implemented in this PR?
i.e. !(a && b || c) -> !(a && b) && !c -> (!a || !b) && !c

@ahoppen
Copy link
Member

ahoppen commented Jul 16, 2024

Do you mean recursive because a boolean expression is nested inside a !? I think support for these kinds of expressions is mandator for a DeMorgan code action.

@AppAppWorks
Copy link
Contributor Author

Do you mean that whenever the code action results in any child expression like !(...), we should apply DeMorgan to such child expression recursively until no !(...) is found as a result of any DeMorgan action?

e.g. !(((a && b) || c) || d) -> !((a && b) || c) && !d -> !(a && b) && !c && !d -> (!a || !b) && !c && !d

Or do you mean that the code action will also apply recursively to all DeMorgan-able descendent expressions of the outermost DeMorgan-able expressions?

e.g. !(!(!(a && b) || c) || d) -> (!(a && b) || c) && !d -> (!a || !b || c) && !d

@ahoppen
Copy link
Member

ahoppen commented Jul 17, 2024

I would expect the former. My logic would be:

  • When applying DeMorgan, add a ! to the target expression to convert (or remove it if ! already exists), then recursively DeMorgan-negate the children
  • DeMorgan-negating an expression prefixed with ! will just drop the ! because that simplifies the code
  • DeMorgan-negating a binary operator flips the operator and then DeMorgan-negates the operands.

With those rules, the transformation of your second example would be

!(!(!(a && b) || c) || d) -> !!(!(a && b) || c) && !d -> (!(a && b) || c) && !d

@AppAppWorks
Copy link
Contributor Author

The latest commit is almost a complete rewrite of the code action. I've adopted a more succinct naming convention, simplified code paths, and covered more edge cases such as adding parentheses when precedence orders are flipped after conversion, and expansion on ternary expressions.

created `ApplyDeMorganLaw` to provide code actions for generalised DeMorgan's conversion
- matches with the outermost applicable expression
- supports both logical and bitwise expressions
- parentheses are created minimally
added SwiftOperators from swift-syntax as a new dependency to SourceKitLSP
registered in `SyntaxCodeActions.allSyntaxCodeActions`
registered in Sources/SourceKitLSP/CMakeLists.txt
added tests in `CodeActionTests`
@AppAppWorks
Copy link
Contributor Author

By the way I wonder whether there is any determining factor for creating a code action under SourceKitLSP vs a refactoring action under SwiftRefactor?

Copy link
Member

@ahoppen ahoppen left a comment

Choose a reason for hiding this comment

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

Nice. I think the recursive design is the correct one. I do wonder whether the implementation can be simplified a little bit by having fewer intermediate types. I left some comments inline but it could be that I missed something. Please let me know what you think.

}

/// A pseudo namespace for all facilities necessary for DeMorgan conversion.
private enum DeMorgan {
Copy link
Member

Choose a reason for hiding this comment

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

Do we need the enum? Making the types fileprivate should achieve the same goal, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

True, that namespace was more of part of my thought process during this rewrite.

let negation = Negation(prefixExpr),
let complement = Expr(expr: negation.expr).negatedPropositions(exprType: negation.exprType)
{
// TODO: remove parentheses?
Copy link
Member

Choose a reason for hiding this comment

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

I think it’s fine to keep the parentheses. If the user wrote them – let’s keep them. But I don’t have very strong opinions here and am happy to go with whatever is easier to implement.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was thinking about the backwards conversion from a complement to which this code action has added parentheses.

Comment on lines +463 to +464
\.leadingTrivia,
prefixExpr.leadingTrivia + prefixExpr.expression.leadingTrivia
Copy link
Member

Choose a reason for hiding this comment

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

Should we also include the trailing trivia of prefixExpr? There’s Trivia.merging that we use in other places to ensure that we don’t end up with duplicate spaces etc.

Suggested change
\.leadingTrivia,
prefixExpr.leadingTrivia + prefixExpr.expression.leadingTrivia
prefixExpr.leadingTrivia.merging(prefixExpr.trailingTrivia).merging(prefixExpr.expression.leadingTrivia)


init(expr: ExprSyntax) {
if let tuple = expr.as(TupleExprSyntax.self),
let only = tuple.elements.only
Copy link
Member

Choose a reason for hiding this comment

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

We have singleUnlabledExpression that extracts the expression.

Suggested change
let only = tuple.elements.only
let parenthesizedExpression = tuple.elements.singleUnlabledExpression

/// // mapped to
/// !((a && !(b is String)))
/// ```
func map(negation: Negation) -> Negated {
Copy link
Member

Choose a reason for hiding this comment

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

map seems like a pretty ambiguous term here but I’m struggling to find anything more meaningful. My feeling is also that the negation parameter and self duplicate some information. Just a thought: Could Negation just store the underlying PrefixOperatorExprSyntax that it was created from and then offer exprType and expr as computed properties (or stored properties if that’s better for performance)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I stored the negated expr of PrefixOperatorExprSyntax directly into Negation because it's what all the intermediate data types do. It's okay to turn exprType and expr into computed properties.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

map seems like a pretty ambiguous term here but I’m struggling to find anything more meaningful.

I had functors in mind when choosing map as the name, regrettably I also struggled with finding a better name.

Comment on lines +210 to +212
case single(TupleExprSyntax, desinglified: ExprSyntax)
/// A non-single expression at its root. The expression of interest.
case other(ExprSyntax)
Copy link
Member

Choose a reason for hiding this comment

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

Do we really have to have an enum with these two cases? Wouldn’t it be easier to just operate on ExprSyntax and have a property that looks through all parentheses? Something like

extension ExprSyntax {
  // Equivalent of the `desinglified` associated value in `single`
  var lookingThroughParentheses: ExprSyntax {
    if let tuple = self.as(TupleExprSyntax.self), 
      let underlyingExpr = tuple.singleUnlabledExpression?.lookingThroughParentheses else {
      return underlyingExpr
    }
    return self
  }

  // Equivalent of checking for the `single` case.
  var isParenthesizedExpression: Bool {
    return self.as(TupleExprSyntax.self)?.isParentheses ?? false
  }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

During this rewrite I've found explicit nominal types tremendously helpful for my thought process because I've been confused for so many times by conflating SwiftSyntax types with domain specific concepts.

I'm open to remove these aids for thought at this stage ;)

Copy link
Member

Choose a reason for hiding this comment

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

While reading through the code, they confused me more than they helped me. I usually thought: Why can’t we just return the rewritten expression? Also, the map functions taking both a syntax node and one of the types you defined confused me a bit because they seemed to reason about to very similar but only related, not equivalent types simultaneously.

You did spend more time thinking about this than I did and I might be missing something, but if you could find the energy to write an implementation without the intermediate types that you define, I would very much like to see that. Maybe it will convince me that the current design is the correct one and we find the correct pieces of documentation that I would have needed to reason about it.

Copy link
Contributor Author

@AppAppWorks AppAppWorks Aug 27, 2024

Choose a reason for hiding this comment

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

While reading through the code, they confused me more than they helped me. I usually thought: Why can’t we just return the rewritten expression? Also, the map functions taking both a syntax node and one of the types you defined confused me a bit because they seemed to reason about to very similar but only related, not equivalent types simultaneously.

It's a fair observation :) I'd leaned much towards adopting the terminology of the de Morgan's law when rewriting this code action because, for me, it'd provided a better conceptual mapping to the business logic by abstracting away the minute details of SwiftSyntax. I've struggled with and bikeshedded on naming everything, the picture didn't become clearer until I did a complete divorce from SwiftSyntax parlance.

You did spend more time thinking about this than I did and I might be missing something, but if you could find the energy to write an implementation without the intermediate types that you define, I would very much like to see that. Maybe it will convince me that the current design is the correct one and we find the correct pieces of documentation that I would have needed to reason about it.

Sure, I can still expend some time doing a minor rewrite before the start of the new school term ;)

{
(
self.map(negatedExpr: negatedPropositions.expr),
negatedPropositions.operator.kind == .and ? .orToAnd : .andToOr
Copy link
Member

Choose a reason for hiding this comment

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

Can you add explicit return here and in the else clause because swift-syntax needs to compile with Swift 5.8, which doesn’t have if expressions. I think the same applies to a few other methods as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Isn't SourceKitLSP compiled with Swift 5.9?

Copy link
Member

Choose a reason for hiding this comment

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

You are right. The code just uses so many swift-syntax types that I thought I was in the swift-syntax repo.

I still think that explicit returns would help readability here, though. Especially with the returned tuple.


/// Represents the negated expression of an instance of propositions.
private struct NegatedPropositions {
struct Operator {
Copy link
Member

Choose a reason for hiding this comment

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

I feel like the Operator type is a little overkill here. Wouldn’t it easier to just have a function that returns the negated operator’s TokenKind and ExprType?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We need something more than TokenKind here, (i.e. to tell AND or OR)

Copy link
Member

Choose a reason for hiding this comment

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

Do we need a whole type for that though? Wouldn’t it be sufficient to have

// In `NegatedPropositions`
var exprType: ExprType? {
  switch expr.operator.tokenKind {
  case .binaryOperator("|"):
    return .bitwise
  case .binaryOperator("&"):
    return .bitwise
  case .binaryOperator("||"):
    return .boolean
  case .binaryOperator("&&"):
    return .boolean
  default:
    return nil
  }
}

And where we check for operator.kind == .and also just check expr.operator.tokenKind == .binaryOperator("&") || expr.operator.tokenKind == .binaryOperator("&&").

Or alternatively, if you prefer add

extension TokenKind {
  var exprType: ExprType? {
    switch expr.operator.tokenKind {
    case .binaryOperator("|"):
      return .bitwise
    case .binaryOperator("&"):
      return .bitwise
    case .binaryOperator("||"):
      return .boolean
    case .binaryOperator("&&"):
      return .boolean
    default:
      return nil
    }
  }

  var isAnd: Bool {
    switch expr.operator.tokenKind {
    case .binaryOperator("&"):
      return .bitwise
    case .binaryOperator("&&"):
      return .boolean
    default:
      return nil
    }
  }
}

The set of and/or operators hasn’t changed since C was introduced in the 70s (or even before in other language), so I don’t think we need an abstraction layer for it.

Comment on lines +572 to +573
leftNegated = Expr(expr: infixExpr.leftOperand).negated(exprType: self.operator.exprType)
rightNegated = Expr(expr: infixExpr.rightOperand).negated(exprType: self.operator.exprType)
Copy link
Member

Choose a reason for hiding this comment

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

I think this is the same in both if branches, so we should be able to extract it to the definition of leftNegated.

}

/// Represents the negated expression of an instance of propositions.
private struct NegatedPropositions {
Copy link
Member

Choose a reason for hiding this comment

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

Do we need the NegatedPropositions type at all? It seems like all we ever do with it is to pass it into Expr.map, so instead of having the NegatedPropositions type, could we just have a function that returns the negated Expr?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We need to tell from negated propositions the type of its operator to determine whether we need to add extra parentheses to preserve precedence order. We can turn operator into a computed property though.

Copy link
Member

Choose a reason for hiding this comment

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

Couldn’t you do that by running the negation and then when realizing that you might need to add operators, get the transformed operator using something like expr.as(BinaryExprSyntax.self)?.operator? That way we wouldn’t have to pass around redundant state.

@AppAppWorks
Copy link
Contributor Author

AppAppWorks commented Aug 27, 2024

In this rewrite it was a deliberate decision to decouple domain specific concepts from Swift Syntax's parlance by creating explicit nominal types, as I thought it'd lay a good foundation for future implementations of other boolean/bitwise algebraic transformations, such as association, distribution, absorption, etc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Code action to apply DeMorgan's law
2 participants