Skip to content

Commit d812cf0

Browse files
savetheclocktowerAerijo
authored andcommitted
Bring the changes from atom#281 into atom#288.
1 parent 9a0e477 commit d812cf0

File tree

5 files changed

+231
-31
lines changed

5 files changed

+231
-31
lines changed

lib/insertion.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
const {transformWithSubstitution} = require('./util')
22

33
class Insertion {
4-
constructor ({range, substitution, choices=[], transformResolver}) {
4+
constructor ({range, substitution, references, choices=[], transformResolver}) {
55
this.range = range
66
this.substitution = substitution
7+
this.references = references
78
if (substitution && substitution.replace === undefined) {
89
substitution.replace = ''
910
}

lib/snippet-expansion.js

+162-29
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,21 @@ module.exports = class SnippetExpansion {
1212
this.cursor = cursor
1313
this.snippets = snippets
1414
this.subscriptions = new CompositeDisposable
15-
this.tabStopMarkers = []
15+
this.insertionsByIndex = []
16+
this.markersForInsertions = new Map()
17+
18+
// The index of the active tab stop. We don't use the tab stop's own
19+
// numbering here; we renumber them consecutively starting at 0 in the order
20+
// in which they should be visited. So `$1` will always be index `0` in the
21+
// above list, and `$0` (if present) will always be the last index.
22+
this.tabStopIndex = null
23+
24+
// If, say, tab stop 4's placeholder references tab stop 2, then tab stop
25+
// 4's insertion goes into this map as a "related" insertion to tab stop 2.
26+
// We need to keep track of this because tab stop 4's marker will need to be
27+
// replaced while 2 is the active index.
28+
this.relatedInsertionsByIndex = new Map()
29+
1630
this.selections = [this.cursor.selection]
1731

1832
const startPosition = this.cursor.selection.getBufferRange().start
@@ -29,8 +43,11 @@ module.exports = class SnippetExpansion {
2943

3044
const tabStops = this.tabStopList.toArray()
3145
this.ignoringBufferChanges(() => {
46+
// Insert the snippet body at the cursor.
3247
const newRange = this.cursor.selection.insertText(body, {autoIndent: false})
3348
if (this.tabStopList.length > 0) {
49+
// Listen for cursor changes so we can decide whether to keep the
50+
// snippet active or terminate it.
3451
this.subscriptions.add(this.cursor.onDidChangePosition(event => this.cursorMoved(event)))
3552
this.subscriptions.add(this.cursor.onDidDestroy(() => this.cursorDestroyed()))
3653
this.placeTabStopMarkers(tabStops)
@@ -49,9 +66,16 @@ module.exports = class SnippetExpansion {
4966

5067
cursorMoved ({oldBufferPosition, newBufferPosition, textChanged}) {
5168
if (this.settingTabStop || (textChanged && !this.isUndoingOrRedoing)) { return }
52-
const itemWithCursor = this.tabStopMarkers[this.tabStopIndex].find(item => item.marker.getBufferRange().containsPoint(newBufferPosition))
5369

54-
if (itemWithCursor && !itemWithCursor.insertion.isTransformation()) { return }
70+
const insertionAtCursor = this.insertionsByIndex[this.tabStopIndex].find((insertion) => {
71+
let marker = this.markersForInsertions.get(insertion)
72+
return marker.getBufferRange().containsPoint(newBufferPosition)
73+
})
74+
75+
if (insertionAtCursor && !insertionAtCursor.isTransformation()) {
76+
// The cursor is still inside an insertion. Return so that the snippet doesn't get destroyed.
77+
return
78+
}
5579

5680
// we get here if there is no item for the current index with the cursor
5781
if (this.isUndoingOrRedoing) {
@@ -95,31 +119,35 @@ module.exports = class SnippetExpansion {
95119
}
96120

97121
applyAllTransformations () {
98-
this.tabStopMarkers.forEach((item, index) => this.applyTransformations(index))
122+
this.insertionsByIndex.forEach((_, index) => this.applyTransformations(index))
99123
}
100124

101125
applyTransformations (tabStop) {
102-
const items = [...this.tabStopMarkers[tabStop]]
103-
if (items.length === 0) { return }
126+
const insertions = [...this.insertionsByIndex[tabStop]]
127+
if (insertions.length === 0) { return }
104128

105-
const primary = items.shift()
106-
const primaryRange = primary.marker.getBufferRange()
129+
const primaryInsertion = insertions.shift()
130+
const primaryRange = this.markersForInsertions.get(primaryInsertion).getBufferRange()
107131
const inputText = this.editor.getTextInBufferRange(primaryRange)
108132

109133
this.ignoringBufferChanges(() => {
110-
for (const item of items) {
111-
const {marker, insertion} = item
112-
var range = marker.getBufferRange()
113-
134+
for (const insertion of insertions) {
114135
// Don't transform mirrored tab stops. They have their own cursors, so
115136
// mirroring happens automatically.
116137
if (!insertion.isTransformation()) { continue }
117138

139+
let marker = this.markersForInsertions.get(insertion)
140+
let range = marker.getBufferRange()
141+
118142
var outputText = insertion.transform(inputText)
119143

120144
this.editor.setTextInBufferRange(range, outputText)
121145
// this.editor.buffer.groupLastChanges()
122146

147+
// Manually adjust the marker's range rather than rely on its internal
148+
// heuristics. (We don't have to worry about whether it's been
149+
// invalidated because setting its buffer range implicitly marks it as
150+
// valid again.)
123151
const newRange = new Range(
124152
range.start,
125153
range.start.traverse(getEndpointOfText(outputText))
@@ -130,42 +158,125 @@ module.exports = class SnippetExpansion {
130158
}
131159

132160
placeTabStopMarkers (tabStops) {
161+
// Tab stops within a snippet refer to one another by their external index
162+
// (1 for $1, 3 for $3, etc.). We respect the order of these tab stops, but
163+
// we renumber them starting at 0 and using consecutive numbers.
164+
//
165+
// Luckily, we don't need to convert between the two numbering systems very
166+
// often. But we do have to build a map from external index to our internal
167+
// index. We do this in a separate loop so that the table is complete before
168+
// we need to consult it in the following loop.
169+
let indexTable = {}
170+
Object.keys(tabStops).forEach((key, index) => {
171+
let tabStop = tabStops[key]
172+
indexTable[tabStop.index] = index
173+
})
133174
const markerLayer = this.getMarkerLayer(this.editor)
134175

176+
let tabStopIndex = -1
135177
for (const tabStop of tabStops) {
178+
tabStopIndex++
136179
const {insertions} = tabStop
137-
const markers = []
138-
139180
if (!tabStop.isValid()) { continue }
140181

141182
for (const insertion of insertions) {
142-
const marker = markerLayer.markBufferRange(insertion.range)
143-
markers.push({
144-
index: markers.length,
145-
marker,
146-
insertion
147-
})
183+
const {range: {start, end}} = insertion
184+
let references = null
185+
if (insertion.references) {
186+
references = insertion.references.map(external => indexTable[external])
187+
}
188+
// Since this method is only called once at the beginning of a snippet
189+
// expansion, we know that 0 is about to be the active tab stop.
190+
let shouldBeInclusive = (tabStopIndex === 0) || (references && references.includes(0))
191+
192+
const marker = markerLayer.markBufferRange(insertion.range, {exclusive: !shouldBeInclusive})
193+
this.markersForInsertions.set(insertion, marker)
194+
if (references) {
195+
let relatedInsertions = this.relatedInsertionsByIndex.get(tabStopIndex) || []
196+
relatedInsertions.push(insertion)
197+
this.relatedInsertionsByIndex.set(tabStopIndex, relatedInsertions)
198+
}
148199
}
149200

150-
this.tabStopMarkers.push(markers)
201+
// Since we have to replace markers in place when we change their
202+
// exclusivity, we'll store them in a map keyed on the insertion itself.
203+
this.insertionsByIndex[tabStopIndex] = insertions
151204
}
152205

153206
this.setTabStopIndex(0)
154207
this.applyAllTransformations()
155208
}
156209

210+
// When two insertion markers are directly adjacent to one another, and the
211+
// cursor is placed right at the border between them, the marker that should
212+
// "claim" the newly-typed content will vary based on context.
213+
//
214+
// All else being equal, that content should get added to the marker (if any)
215+
// whose tab stop is active (or the marker whose tab stop's placeholder
216+
// references an active tab stop). The `exclusive` setting controls whether a
217+
// marker grows to include content added at its edge.
218+
//
219+
// So we need to revisit the markers whenever the active tab stop changes,
220+
// figure out which ones need to be touched, and replace them with markers
221+
// that have the settings we need.
222+
adjustTabStopMarkers (oldIndex, newIndex) {
223+
// Take all the insertions belonging to the newly-active tab stop (and all
224+
// insertions whose placeholders reference the newly-active tab stop) and
225+
// change their markers to be inclusive.
226+
let insertionsForNewIndex = [
227+
...this.insertionsByIndex[newIndex],
228+
...(this.relatedInsertionsByIndex.get(newIndex) || [])
229+
]
230+
231+
for (let insertion of insertionsForNewIndex) {
232+
this.replaceMarkerForInsertion(insertion, {exclusive: false})
233+
}
234+
235+
// Take all the insertions whose markers were made inclusive when they
236+
// became active and restore their original marker settings.
237+
let insertionsForOldIndex = [
238+
...this.insertionsByIndex[oldIndex],
239+
...(this.relatedInsertionsByIndex.get(oldIndex) || [])
240+
]
241+
242+
for (let insertion of insertionsForOldIndex) {
243+
this.replaceMarkerForInsertion(insertion, {exclusive: true})
244+
}
245+
}
246+
247+
replaceMarkerForInsertion (insertion, settings) {
248+
let marker = this.markersForInsertions.get(insertion)
249+
250+
// If the marker is invalid or destroyed, return it as-is. Other methods
251+
// need to know if a marker has been invalidated or destroyed, and there's
252+
// no case in which we'd need to change the settings on such a marker
253+
// anyway.
254+
if (!marker.isValid() || marker.isDestroyed()) {
255+
return marker
256+
}
257+
258+
// Otherwise, create a new marker with an identical range and the specified
259+
// settings.
260+
let range = marker.getBufferRange()
261+
let replacement = this.getMarkerLayer(this.editor).markBufferRange(range, settings)
262+
263+
marker.destroy()
264+
this.markersForInsertions.set(insertion, replacement)
265+
return replacement
266+
}
267+
157268
goToNextTabStop () {
158269
const nextIndex = this.tabStopIndex + 1
159270

160271
// if we have an endstop (implicit ends have already been added) it will be the last one
161-
if (nextIndex === this.tabStopMarkers.length - 1 && this.tabStopList.hasEndStop) {
272+
if (nextIndex === this.insertionsByIndex.length - 1 && this.tabStopList.hasEndStop) {
162273
const succeeded = this.setTabStopIndex(nextIndex)
163274
this.destroy()
164275
return {succeeded, isDestroyed: true}
165276
}
166277

167278
// we are not at the end, and the next is not the endstop; just go to next stop
168-
if (nextIndex < this.tabStopMarkers.length) {
279+
if (nextIndex < this.insertionsByIndex.length) {
169280
const succeeded = this.setTabStopIndex(nextIndex)
170281
if (succeeded) { return {succeeded, isDestroyed: false} }
171282
return this.goToNextTabStop()
@@ -190,26 +301,38 @@ module.exports = class SnippetExpansion {
190301
}
191302

192303
setTabStopIndex (tabStopIndex) {
304+
let oldIndex = this.tabStopIndex
193305
this.tabStopIndex = tabStopIndex
306+
307+
// Set a flag before we move any selections so that our change handlers
308+
// will know that the movements were initiated by us.
194309
this.settingTabStop = true
310+
311+
// Keep track of whether we replaced any selections or cursors.
195312
let markerSelected = false
196313

197-
const items = this.tabStopMarkers[this.tabStopIndex]
198-
if (items.length === 0) { return false }
314+
let insertions = this.insertionsByIndex[this.tabStopIndex]
315+
if (insertions.length === 0) { return false }
199316

200317
const ranges = []
201318
let hasTransforms = false
202-
for (const item of items) {
203-
const {marker, insertion} = item
319+
// Go through the active tab stop's markers to figure out where to place
320+
// cursors and/or selections.
321+
for (const insertion of insertions) {
322+
const marker = this.markersForInsertions.get(insertion)
204323
if (marker.isDestroyed() || !marker.isValid()) { continue }
205324
if (insertion.isTransformation()) {
325+
// Set a flag for later, but skip transformation insertions because
326+
// they don't get their own cursors.
206327
hasTransforms = true
207328
continue
208329
}
209330
ranges.push(marker.getBufferRange())
210331
}
211332

212333
if (ranges.length > 0) {
334+
// We have new selections to apply. Reuse existing selections if
335+
// possible, and destroy the unused ones if we already have too many.
213336
for (const selection of this.selections.slice(ranges.length)) { selection.destroy() }
214337
this.selections = this.selections.slice(0, ranges.length)
215338
for (let i = 0; i < ranges.length; i++) {
@@ -223,20 +346,30 @@ module.exports = class SnippetExpansion {
223346
this.selections.push(newSelection)
224347
}
225348
}
349+
// We placed at least one selection, so this tab stop was successfully
350+
// set. Update our return value.
226351
markerSelected = true
227352
}
228353

229354
this.settingTabStop = false
230355
// If this snippet has at least one transform, we need to observe changes
231356
// made to the editor so that we can update the transformed tab stops.
232-
if (hasTransforms) { this.snippets.observeEditor(this.editor) }
357+
if (hasTransforms) {
358+
this.snippets.observeEditor(this.editor)
359+
} else {
360+
this.snippets.stopObservingEditor(this.editor)
361+
}
362+
363+
if (oldIndex !== null) {
364+
this.adjustTabStopMarkers(oldIndex, this.tabStopIndex)
365+
}
233366

234367
return markerSelected
235368
}
236369

237370
destroy () {
238371
this.subscriptions.dispose()
239-
this.tabStopMarkers = []
372+
this.insertionsByIndex = []
240373
}
241374

242375
getMarkerLayer () {

lib/snippet.js

+19-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,19 @@ const TabStopList = require('./tab-stop-list')
33
const {transformWithSubstitution} = require('./util')
44
const {VariableResolver, TransformResolver} = require('./resolvers')
55

6+
const tabStopsReferencedWithinTabStopContent = (segment) => {
7+
let results = []
8+
for (let item of segment) {
9+
if (item.index) {
10+
results.push(
11+
item.index,
12+
...tabStopsReferencedWithinTabStopContent(item.content)
13+
)
14+
}
15+
}
16+
return new Set(results)
17+
}
18+
619
module.exports = class Snippet {
720
constructor(params) {
821
this.name = params.name
@@ -80,8 +93,13 @@ function stringifyTabstop (node, params, acc) {
8093
const index = node.index === 0 ? Infinity : node.index
8194
const start = new Point(acc.row, acc.column)
8295
stringifyContent(node.content, params, acc)
96+
let referencedTabStops = tabStopsReferencedWithinTabStopContent(node.content)
8397
const range = new Range(start, [acc.row, acc.column])
84-
acc.tabStopList.findOrCreate({index, snippet: this}).addInsertion({range, substitution: node.substitution})
98+
acc.tabStopList.findOrCreate({index, snippet: this}).addInsertion({
99+
range,
100+
substitution: node.substitution,
101+
references: [...referencedTabStops]
102+
})
85103
}
86104

87105
function stringifyChoice (node, params, acc) {

spec/fixtures/test-snippets.cson

+10
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,13 @@
143143
'has a transformed tab stop such that it is possible to move the cursor between the ordinary tab stop and its transformed version without an intermediate step':
144144
prefix: 't18'
145145
body: '// $1\n// ${1/./=/g}'
146+
"has two tab stops adjacent to one another":
147+
prefix: 't19'
148+
body: """
149+
${2:bar}${3:baz}
150+
"""
151+
"has several adjacent tab stops, one of which has a placeholder with a reference to another tab stop at its edge":
152+
prefix: 't20'
153+
body: """
154+
${1:foo}${2:bar}${3:baz $1}$4
155+
"""

0 commit comments

Comments
 (0)