Skip to content
This repository was archived by the owner on Dec 15, 2022. It is now read-only.

Add support for simple regex transformations in tab stops. #260

Merged
merged 20 commits into from
Jan 4, 2018
Merged

Add support for simple regex transformations in tab stops. #260

merged 20 commits into from
Jan 4, 2018

Conversation

savetheclocktower
Copy link
Contributor

Note: this is my first deep-dive into adding an Atom feature and I’m happy to get pushback on code formatting, architectural choices, and the like.

Description of the Change

This implements a slimmed-down version of snippet transformations. TextMate’s version of this feature is documented here.

For example, this snippet:

<${1:p}></${1/[ ]+.*$//}>

can be used like this:

snippet-transformation-demo

Design

I went through a bunch of possible ways to design this. Here’s what I landed on:

  • Update the pegjs file so that we can parse the new syntax.
  • Introduce the concept of an Insertion, which belongs to a tab stop and can either mirror or transform its input.
  • Upon expanding a snippet or moving to a new tab stop, we check if the active tab stop has at least one transformation. If so, we observe the buffer so that every transactional update that happens while that tab stop is active (except for the transactions we initiate) results in a recomputation of the transform output.
  • For undo and redo to work the way we want, we have to take the changes that occur as a result of transformations and fold them into the change that kicked off the transformation in the first place. And we need to spy on the history provider so that we can skip trying to reapply transformations upon undo or redo.

Worth noting

Mirrored tab stops have always been treated like multiple selections…

snippet-mirror mov

…and that doesn’t change. But transformed tab stops are not treated like selections, because I think a user wouldn’t reasonably expect the same input to produce different output when applied to multiple selections.

Transformed tab stops cannot have placeholders; the first transformation gets applied as soon as the snippet is expanded, so any placeholder value would likely be lost immediately.

What is and isn't included

As explained in this comment, I wanted to go minimal here. The find-and-replace syntax roughly corresponds to the two arguments you'd pass to String#replace (the regex is automatically given the global flag). There's one extra feature that I brought over from TextMate format strings: the ability to control case through the \u, \l, \U, \L, and \E flags:

  • \u and \l will force the next character to uppercase or lowercase, respectively.
  • \U and \L will force the rest of the string to uppercase or lowercase, respectively (or until we encounter a different case directive or \E).
  • \E stops the effects of \U and \L.

There are lots of other features of TextMate format strings, but a lot of them are obscure. I went with this small extension because it's quite useful and is also supported by Sublime Text's snippet transformation syntax. Other features, like conditional insertions, could be added rather easily in the future just by updating the parser and the Insertion class.

Alternate Designs

I don’t like how a snippet expansion needs to listen to TextBuffer changes in order to do transformations, but I didn’t see another way. An asynchronous listener doesn’t act quickly enough. But we do listen at the transaction level (TextBuffer#onDidChangeText) rather than the keystroke level (TextEditor#onDidChange).

If there is a simpler way to do this, I’m all for it.

Benefits

Makes snippets far more useful. There are a couple more examples in the (duplicate) ticket that I opened.

Possible Drawbacks

Not sure. Increased code complexity, perhaps?

This PR doesn't change any existing behavior — barely any of the existing tests even needed to be touched — and the theoretical performance penalty of observing TextBuffer transactions should only be encountered when using a snippet with a transformation; for existing use cases there should be no performance regression.

Applicable Issues

Ticket #40 contains the original request.

@savetheclocktower savetheclocktower mentioned this pull request Dec 22, 2017
@nathansobo nathansobo self-assigned this Jan 3, 2018
Copy link
Contributor

@nathansobo nathansobo left a comment

Choose a reason for hiding this comment

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

Aside from some minor changes, this is looking really excellent. Solid test coverage, well thought out design, a great PR description, and a super substantial contribution. This PR was a pleasure to review.

I plan on testing this out tomorrow morning. @ungb or @Ben3eeE, either of your help breaking this would be appreciated.

@@ -608,6 +634,7 @@ describe "Snippets extension", ->
editor.insertText('t9b')
simulateTabKeyEvent()
editor.getCursors()[0].destroy()
buf = editor.getCursorBufferPosition()
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 needed?

@@ -0,0 +1,46 @@
/** @babel */
Copy link
Contributor

Choose a reason for hiding this comment

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

It would be nice to avoid using Babel on new files. Everything except the fancy import syntax works fine.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Cool, was unclear on what the best practice was for new files.


# Create a checkpoint here to consolidate all the changes we just made into
# the transaction that prompted them.
@makeCheckpoint(editor)
Copy link
Contributor

Choose a reason for hiding this comment

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

If I'm reading this right, you're doing this to group any transformations with a previous transaction because the transaction is already closed by the time you receive the event. But I'm confused as to how the initial checkpoint preceding any changes gets populated.

Incidentally, in 1.25 we're adding a groupLastChanges method to the buffer that lets you combine the last two transactions on the undo stack, which might make this easier. But you can't really use it until Atom 1.25 hits stable without breaking this package's tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Working off of recollection here, but I think this is the deal:

The initial snippet expansion is easy because it's invoking a command. Everything that we do in response to that command is automatically atomic because of the TextEditor#transact call.

The checkpoint logic is needed only for typing that happens while a snippet is active but not in response to a command invocation. Consider: If I have a snippet like $1\n${1/./\\u$1} (i.e., transforming what I type to all-caps on the following line):

  1. I type its abbreviation and expand the snippet.
  2. I type a.
  3. Atom inserts A on the following line.

We're observing text editor changes, so we act in between steps 2 and 3, but we have to bundle the changes of steps 2 and 3 into one atom — i.e., we have to create a transaction that includes something that already happened. The way I arrived at to do this was to make the checkpoint after the initial snippet expansion and then make a new one each time we did step 3. And right before I make a new checkpoint I bundle everything together that happened since the last checkpoint.

This took a lot of time to reason through. I wrote a lot of tests around this precisely because I suspected I didn't understand it correctly.

It does sound like groupLastChanges would make this easier — it would theoretically allow me to toss out checkpoints altogether and duct-tape the changes i'm making with the user action that prompted them.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

After re-reading your question, I'm pretty sure all I did was restate the part that you already understood. The shorter answer to your question is “I don't know.” I’ll investigate when I get a chance.

Copy link
Contributor

Choose a reason for hiding this comment

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

The way I arrived at to do this was to make the checkpoint after the initial snippet expansion and then make a new one each time we did step 3

Actually I think this answered my question perfectly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well, after my long comment I skimmed the source again and can't 100% guarantee that the code does what I claimed it did in the part you quoted. 🤷‍♂️ I can't find the part where I make the initial checkpoint; the makeCheckpoint method seems like it only gets called after we set up the listener, not on initial snippet expansion.

@@ -298,6 +299,13 @@ module.exports =
expandSnippetsUnderCursors: (editor) ->
return false unless snippet = @snippetToExpandUnderCursor(editor)

@getStore(editor).observeHistory({
Copy link
Contributor

Choose a reason for hiding this comment

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

We should really supply a flag in the change event indicating when the change is coming from an undo or redo. If you'd have any interest in adding that, I'd be happy to shepherd it. Then we could simplify this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, that’s certainly a better idea. I’ll investigate.

const range = new Range(0, 0);

describe('Insertion', () => {

Copy link
Contributor

Choose a reason for hiding this comment

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

Super minor, but noticing some extra newlines in the file such as this one.

@@ -0,0 +1,63 @@
SnippetHistoryProvider = require './snippet-history-provider'
Copy link
Contributor

@nathansobo nathansobo Jan 4, 2018

Choose a reason for hiding this comment

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

Would you be cool doing this file in ordinary JS?

@nathansobo nathansobo dismissed their stale review January 4, 2018 21:07

I went ahead and made the changes myself to save you the trouble since it's been a while since you opened this.

@nathansobo nathansobo merged commit b7b6200 into atom:master Jan 4, 2018
@nathansobo
Copy link
Contributor

Published as snippets@1.2.0. Thanks for the great work adding this. You combined a bunch of APIs in a pretty complex piece of code to add a really cool feature. I've gone ahead and given you commit bit on this repository ⚡️.

@savetheclocktower
Copy link
Contributor Author

Thanks! I'll revisit this when 1.25 is out, and the undo/redo thing will be on my radar as well.

@nobleach
Copy link

nobleach commented Jun 1, 2018

Forgive my misunderstanding but, is this actually available in non-beta releases? I see it's published in snippets@1.2.0 (current is 1.3.3).

@savetheclocktower
Copy link
Contributor Author

Yeah, it went out with Atom 1.25.0.

@nobleach
Copy link

nobleach commented Jun 1, 2018

That's great news! Looks fantastic. I'll dig around in the source a bit to see how to use it. Thanks!

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

Successfully merging this pull request may close these issues.

3 participants