Skip to content
← Back

src/syntax/process/printer/formatter.ghul

1
namespace Syntax.Process.Printer is
2
use Collections
3
use Ghul
4
5
use Trees
6
use Source
7
8
use Lexical.TRIVIA
9
10
// The ghūl code formatter.
11
//
12
// Extends the plain GHUL printer (which the diagnostics path uses via
13
// Node.to_string and must not be disturbed) and redirects its output
14
// primitives into a DOC tree, so DOC_RENDERER can lay the result out
15
// against a column budget. Comments and blank lines collected by the
16
// tokenizer as TRIVIA are interleaved back in by source position.
17
//
18
// Selected visits are overridden to introduce soft breaks (GROUP/LINE)
19
// where wrapping should happen; everything else inherits the printer's
20
// structural layout unchanged.
21
class FORMATTER: GHUL is
22
_builder: DOC_BUILDER
23
_width: int
24
25
// Types a build settled at inferred sites, written in where the
26
// source left them out. Null prints the source as written.
27
annotations: InferredAnnotations? public
28
29
_annotation(lookup: (InferredAnnotations) -> string?) -> string? =>
30
if let a = annotations then lookup(a) else null fi
31
32
// Written annotations to leave out, so the source is printed with
33
// less than its author wrote. Null prints every annotation.
34
stripper: ANNOTATION_STRIPPER? public
35
36
// Whether the variable being printed is a `let` local: the only
37
// kind of Variables.VARIABLE whose type is an annotation rather
38
// than a signature.
39
_in_let: bool
40
41
_strip(kind: string, location: Source.LOCATION, mode: string, from: Source.LOCATION, to: Source.LOCATION) -> bool =>
42
if let s = stripper then s.strip(kind, location, mode, from, to) else false fi
43
44
_trivia: LIST[TRIVIA]
45
_trivia_index: int
46
47
// Source line of the furthest content emitted so far. Drives
48
// trailing-comment detection: a comment starting on a line we have
49
// already passed trails that line rather than leading the next node.
50
_last_line: int
51
52
// True between opening a block and emitting its first real content;
53
// suppresses a blank line immediately inside a block.
54
_at_block_start: bool
55
56
// True when nothing has been written since the last line break. A
57
// statement whose rendering ends in a block terminator - `od`, `esac` -
58
// leaves the line open, and the statement after it must not be run onto
59
// the same line.
60
_at_line_start: bool
61
62
// True while inside a union that declared a primary-constructor
63
// header. Variants emitted under this flag can drop a sole
64
// `(..)` field list — the rewriter implicitly splices the primary
65
// params for a variant with no field list. Without a primary
66
// header, a sole `..` is a hard error, and dropping the parens
67
// would silently turn the error into valid empty-fields source.
68
_union_has_primary_params: bool
69
70
init(trivia: Iterable[TRIVIA]?, width: int) is
71
super.init()
72
73
_width = width
74
_builder = DOC_BUILDER()
75
76
_trivia = LIST[TRIVIA]()
77
if trivia? then
78
for t in trivia do
79
_trivia.add(t)
80
od
81
fi
82
si
83
84
// Format a parsed file (or any node) and return the rendered text.
85
format(node: Node) -> string is
86
node.accept(self)
87
flush_remaining()
88
89
let text = DOC_RENDERER(_width).render(_builder.build())
90
91
// Strip trailing whitespace from every line (blank lines pick up
92
// indentation from the renderer), then settle each terminator.
93
let lines = Collections.LIST[string]()
94
95
for line in text.split(['\n']) do
96
lines.add(line.trim_end())
97
od
98
99
_settle_terminators(lines)
100
101
let result = System.Text.StringBuilder()
102
let first mut = true
103
for line in lines do
104
if !first then
105
result.append('\n')
106
fi
107
result.append(line)
108
first = false
109
od
110
111
return "{result.to_string().trim_end()}\n"
112
si
113
114
// A statement terminator is written as this marker rather than as
115
// `;`, because whether it is needed depends on what follows it,
116
// which is not known when the statement is emitted.
117
// _settle_terminators resolves every one of them once the text has
118
// been laid out.
119
// A control character, so it cannot occur in the source being
120
// formatted. Held and searched for as a `char`: `index_of(string)`
121
// compares by culture, under which a control character has no
122
// collation weight and so matches at the start of any string at
123
// all, while the `char` overload compares ordinally.
124
TERMINATOR_MARKER: char static => cast char(1)
125
126
write_terminator() is
127
write(TERMINATOR_MARKER)
128
write_line()
129
si
130
131
// A line break stands in for a statement terminator, so a marker
132
// that nothing but a comment follows is dropped. The exception is
133
// the one terminator that carries meaning: adjacent string literals
134
// chain into a single literal across a line break, so a statement
135
// ending on one and followed by a statement starting with one keeps
136
// its `;`. A marker with code after it separates two statements on
137
// one line and is kept.
138
_settle_terminators(lines: Collections.LIST[string]) is
139
let i mut = 0
140
141
while i < lines.count do
142
let line = lines[i]
143
let at = line.index_of(TERMINATOR_MARKER)
144
145
if at >= 0 then
146
let before = line[0..at]
147
let after = line[at + 1..<0]
148
149
let keep =
150
!_is_only_a_comment(after) \/
151
(_ends_with_string(before) /\
152
_next_line_starts_with_string(lines, i))
153
154
lines[i] = if keep then "{before};{after}" else "{before}{after}" fi
155
fi
156
157
i = i + 1
158
od
159
si
160
161
// Whether the rest of a line, after a terminator marker, is nothing
162
// but whitespace and a comment - in which case the line break is
163
// what ends the statement.
164
_is_only_a_comment(rest: string) -> bool is
165
let trimmed = rest.trim()
166
167
if trimmed.length == 0 \/ trimmed.starts_with("//") then
168
return true
169
fi
170
171
return
172
trimmed.starts_with("/*") /\
173
trimmed.ends_with("*/") /\
174
trimmed.index_of("*/") == trimmed.length - 2
175
si
176
177
_ends_with_string(line: string) -> bool is
178
let trimmed = line.trim_end()
179
180
return trimmed.length > 0 /\ trimmed[trimmed.length - 1] == cast char(34)
181
si
182
183
// Comments are transparent to string chaining, so a comment line
184
// between the two literals does not break the chain and is looked
185
// through here too - a block comment as well as a line comment.
186
//
187
// A line this cannot classify - the opening line of a block comment
188
// that runs on, say - is answered as though a literal followed.
189
// The two errors are not equal: a terminator written where none was
190
// needed costs a warning, while one dropped where it was needed
191
// merges two literals into one and changes what the program says.
192
_next_line_starts_with_string(lines: Collections.LIST[string], from: int) -> bool is
193
let i mut = from + 1
194
195
while i < lines.count do
196
let candidate = lines[i].trim()
197
198
if candidate.length == 0 \/ _is_only_a_comment(candidate) then
199
i = i + 1
200
elif candidate.starts_with("/*") then
201
return true
202
else
203
return candidate[0] == cast char(34)
204
fi
205
od
206
207
return false
208
si
209
210
// --- output primitives, redirected into the DOC builder ---
211
212
write(value: string?) is
213
if !value? then
214
return
215
fi
216
217
if value.length > 0 then
218
_at_block_start = false
219
_at_line_start = false
220
fi
221
222
_builder.text(value)
223
si
224
225
write(c: char) is
226
_at_block_start = false
227
_at_line_start = false
228
_builder.text("{c}")
229
si
230
231
// A name that collides with a keyword, or that would tokenise as a
232
// number, only reached the tree via a backtick escape; the inherited
233
// printer drops the backtick and the result no longer re-parses.
234
// Restore it. Operator names are left to visit(Expressions.IDENTIFIER):
235
// they must stay bare in definition and binary-operation positions,
236
// which route through here too.
237
write_name(name: string) is
238
if Lexical.TOKENIZER.is_reserved_word(name) \/ Lexical.TOKENIZER.is_numeric_identifier(name) then
239
write('`')
240
fi
241
write(name)
242
si
243
244
// An operator referred to as a plain value, rather than applied,
245
// reached the tree via a backtick escape and must keep it to re-parse
246
// as an identifier rather than an operator. This is the one identifier
247
// position where an operator escapes; definition names and binary
248
// operations render the operator bare through the inherited visits.
249
visit(identifier: Expressions.IDENTIFIER) is
250
location(identifier)
251
let inner = identifier.identifier
252
if !inner.is_qualified /\ Lexical.TOKENIZER.is_operator_name(inner.name) then
253
write('`')
254
write(inner.name)
255
else
256
inner.accept(self)
257
fi
258
si
259
260
at_line_start: bool => _at_line_start
261
262
write_line() is
263
flush_trailing_comments()
264
_builder.hard_line()
265
_at_line_start = true
266
si
267
268
indent() is
269
_builder.begin_nest(4)
270
_at_block_start = true
271
si
272
273
outdent() is
274
_builder.end_nest()
275
si
276
277
write_indent() is si
278
279
// --- line tracking ---
280
281
location(location: LOCATION) is
282
note(location)
283
si
284
285
note(location: LOCATION?) is
286
if location? /\ location.start_line > _last_line then
287
_last_line = location.start_line
288
fi
289
si
290
291
// --- trivia interleaving ---
292
293
_has_trivia: bool => _trivia_index < _trivia.count
294
295
// Whether any unemitted trivia still sits before `limit` (a
296
// LOCATION.start value). A body with none can take a one-line
297
// rendering, since there is no comment to keep inside it.
298
_has_trivia_before(limit: int) -> bool =>
299
_has_trivia /\ _peek.location.start < limit
300
301
_peek: TRIVIA => _trivia[_trivia_index]
302
303
_consume() is
304
_trivia_index = _trivia_index + 1
305
si
306
307
// Emit any trivia positioned strictly before `limit` (a LOCATION.start
308
// value) that is not a trailing comment of an already-emitted line.
309
flush_leading(limit: int) is
310
while _has_trivia /\ _peek.location.start < limit do
311
let t = _peek
312
313
if isa TRIVIA.BLANK_LINE(t) then
314
// A blank line written inside an expression the layout
315
// joins onto one line has nowhere to go, and emitting it
316
// here puts it after the whole construct instead - one
317
// more blank line on every reformat. Its source line has
318
// already been passed, which is what distinguishes it
319
// from a blank line genuinely separating two constructs.
320
if !_at_block_start /\ _peek.location.start_line > _last_line then
321
_builder.hard_line()
322
fi
323
_consume()
324
else
325
emit_comment(t)
326
_builder.hard_line()
327
_at_block_start = false
328
_consume()
329
fi
330
od
331
si
332
333
// Emit comments that start on a line we have already emitted: they
334
// trail that line. Called from write_line, before the newline.
335
flush_trailing_comments() is
336
while
337
_has_trivia /\
338
_is_comment(_peek) /\
339
_peek.location.start_line <= _last_line
340
do
341
_builder.text(" ")
342
emit_comment(_peek)
343
_consume()
344
od
345
si
346
347
// Emit everything left over (file-trailing comments).
348
flush_remaining() is
349
while _has_trivia do
350
let t = _peek
351
if _is_comment(t) then
352
emit_comment(t)
353
_builder.hard_line()
354
fi
355
_consume()
356
od
357
si
358
359
_is_comment(t: TRIVIA) -> bool =>
360
isa TRIVIA.LINE_COMMENT(t) \/ isa TRIVIA.BLOCK_COMMENT(t)
361
362
// The leading whitespace every non-blank continuation line of a
363
// block comment has in common.
364
_common_indent(lines: string[]) -> int is
365
let common mut = -1
366
let first mut = true
367
368
for line in lines do
369
if !first /\ line.trim().length > 0 then
370
let indent = line.length - line.trim_start().length
371
372
if common < 0 \/ indent < common then
373
common = indent
374
fi
375
fi
376
377
first = false
378
od
379
380
return if common < 0 then 0 else common fi
381
si
382
383
_strip_indent(line: string, amount: int) -> string is
384
let i mut = 0
385
386
while i < amount /\ i < line.length /\ char.is_white_space(line[i]) do
387
i = i + 1
388
od
389
390
return line[i..<0]
391
si
392
393
emit_comment(t: TRIVIA) is
394
note(t.location)
395
396
if let bc: TRIVIA.BLOCK_COMMENT = t then
397
let lines = bc.text.split(['\n'])
398
399
// A continuation line carries the indentation it was
400
// written at, and the renderer indents it again, so
401
// re-emitting it verbatim moves the comment four columns
402
// right on every reformat. Strip what every continuation
403
// line shares and let the renderer supply it instead; the
404
// relative shape of the comment is preserved.
405
let common = _common_indent(lines)
406
407
let first mut = true
408
for line in lines do
409
if !first then
410
_builder.hard_line()
411
_builder.text(_strip_indent(line, common))
412
else
413
_builder.text(line)
414
fi
415
first = false
416
od
417
elif let lc: TRIVIA.LINE_COMMENT = t then
418
_builder.text(lc.text)
419
elif let sb: TRIVIA.SHEBANG = t then
420
_builder.text(sb.text)
421
fi
422
si
423
si
424
si