fountainrb 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +34 -0
- data/LICENSE.txt +21 -0
- data/README.md +373 -0
- data/exe/fountainrb +98 -0
- data/lib/fountainrb/elements.rb +271 -0
- data/lib/fountainrb/errors.rb +9 -0
- data/lib/fountainrb/html_renderer.rb +159 -0
- data/lib/fountainrb/inline.rb +356 -0
- data/lib/fountainrb/parser.rb +137 -0
- data/lib/fountainrb/plain_text_renderer.rb +137 -0
- data/lib/fountainrb/screenplay.rb +285 -0
- data/lib/fountainrb/title_page.rb +186 -0
- data/lib/fountainrb/tokenizer.rb +390 -0
- data/lib/fountainrb/validator.rb +294 -0
- data/lib/fountainrb/version.rb +6 -0
- data/lib/fountainrb.rb +65 -0
- metadata +98 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FountainRB
|
|
4
|
+
# Turns raw Fountain source into a flat stream of typed tokens.
|
|
5
|
+
#
|
|
6
|
+
# The tokenizer is deliberately context sensitive: Fountain's rules depend
|
|
7
|
+
# on blank lines (a character cue must be preceded by one, a transition must
|
|
8
|
+
# be uppercase and end in "TO:", and so on). Each token therefore records
|
|
9
|
+
# the type it would be interpreted as in context.
|
|
10
|
+
class Tokenizer
|
|
11
|
+
# A single classified source line.
|
|
12
|
+
Token = Struct.new(:type, :text, :line, :meta, keyword_init: true) do
|
|
13
|
+
def [](key)
|
|
14
|
+
meta && meta[key]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def forced?
|
|
18
|
+
!!self[:forced]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Lines beginning with one of these (followed by a dot or space) are scene
|
|
23
|
+
# headings. Longer alternatives come first so combined prefixes win over
|
|
24
|
+
# the bare "INT"/"EXT" alternatives. In addition to the prefixes listed by
|
|
25
|
+
# the Fountain spec, EXT/INT and EXT./INT are accepted because they are
|
|
26
|
+
# common in real screenplays.
|
|
27
|
+
SCENE_PREFIX = %r{\A(?:(?:INT|EXT)\.?/(?:INT|EXT)|INT|EXT|EST|I/E)[.\s]}i
|
|
28
|
+
|
|
29
|
+
PAGE_BREAK = /\A={3,}\z/
|
|
30
|
+
SECTION = /\A(#+)[ \t]*(.*)\z/
|
|
31
|
+
SYNOPSIS = /\A=(.*)\z/
|
|
32
|
+
SCENE_NUMBER = /[ \t]*#([A-Za-z0-9._-]+)#[ \t]*\z/
|
|
33
|
+
CHARACTER_EXTENSION = /[ \t]*\(([^()]*)\)[ \t]*\z/
|
|
34
|
+
HARD_BREAK = /\A[ \t]{2,}\z/
|
|
35
|
+
|
|
36
|
+
# @return [Array<String>] the contents of every boneyard in the source
|
|
37
|
+
attr_reader :boneyards
|
|
38
|
+
|
|
39
|
+
def initialize(source, line_offset: 0)
|
|
40
|
+
@boneyards = []
|
|
41
|
+
@line_offset = line_offset.to_i
|
|
42
|
+
@source = remove_boneyards(normalise(source))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Array<String>] the source after boneyards have been removed
|
|
46
|
+
attr_reader :source
|
|
47
|
+
|
|
48
|
+
def lines
|
|
49
|
+
@lines ||= source.split("\n", -1)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Array<Token>]
|
|
53
|
+
def tokens
|
|
54
|
+
@tokens ||= tokenize
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def normalise(source)
|
|
60
|
+
source.to_s.sub(/\A\uFEFF/, "").gsub(/\r\n?/, "\n")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Remove boneyard comments.
|
|
64
|
+
#
|
|
65
|
+
# A boneyard spanning several lines is replaced by the same number of
|
|
66
|
+
# newlines. That keeps line numbers stable and -- because blank lines act
|
|
67
|
+
# as separators in Fountain -- makes the comment behave as if it were
|
|
68
|
+
# never there. An unterminated boneyard comments out the rest of the file.
|
|
69
|
+
def remove_boneyards(source)
|
|
70
|
+
cleaned = source.gsub(%r{/\*.*?\*/}m) do |match|
|
|
71
|
+
@boneyards << match[2...-2]
|
|
72
|
+
"\n" * match.count("\n")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if (open = cleaned.index("/*"))
|
|
76
|
+
@boneyards << cleaned[(open + 2)..].to_s
|
|
77
|
+
cleaned = cleaned[0...open]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
cleaned
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def token(type, line:, text: "", **meta)
|
|
84
|
+
Token.new(type: type, text: text, line: line, meta: meta)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def tokenize
|
|
88
|
+
result = []
|
|
89
|
+
prev_blank = true
|
|
90
|
+
dialogue_open = false
|
|
91
|
+
note_open = false
|
|
92
|
+
index = 0
|
|
93
|
+
|
|
94
|
+
while index < lines.size
|
|
95
|
+
text = lines[index]
|
|
96
|
+
stripped = text.strip
|
|
97
|
+
lineno = index + 1 + @line_offset
|
|
98
|
+
|
|
99
|
+
# A line of two or more spaces is how a writer asks for a blank line
|
|
100
|
+
# inside dialogue. Outside dialogue it is simply a blank line. This
|
|
101
|
+
# check must come before the empty-line check, because a whitespace
|
|
102
|
+
# only line is also "empty" after stripping.
|
|
103
|
+
if text.match?(HARD_BREAK)
|
|
104
|
+
if dialogue_open || note_open
|
|
105
|
+
result << token(:hard_break, line: lineno)
|
|
106
|
+
else
|
|
107
|
+
result << token(:blank, line: lineno)
|
|
108
|
+
prev_blank = true
|
|
109
|
+
end
|
|
110
|
+
index += 1
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
if stripped.empty?
|
|
115
|
+
result << token(:blank, line: lineno)
|
|
116
|
+
prev_blank = true
|
|
117
|
+
dialogue_open = false
|
|
118
|
+
note_open = false
|
|
119
|
+
index += 1
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if PAGE_BREAK.match?(stripped)
|
|
124
|
+
result << token(:page_break, line: lineno, text: stripped)
|
|
125
|
+
prev_blank = true
|
|
126
|
+
dialogue_open = false
|
|
127
|
+
note_open = false
|
|
128
|
+
index += 1
|
|
129
|
+
next
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Sections and synopses are invisible in formatted output, so they do
|
|
133
|
+
# not disturb the blank-line / dialogue context around them.
|
|
134
|
+
if (match = SECTION.match(stripped))
|
|
135
|
+
result << token(:section, line: lineno, text: match[2].strip, level: match[1].length)
|
|
136
|
+
note_open = false
|
|
137
|
+
index += 1
|
|
138
|
+
next
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if (match = SYNOPSIS.match(stripped))
|
|
142
|
+
result << token(:synopsis, line: lineno, text: match[1].strip)
|
|
143
|
+
note_open = false
|
|
144
|
+
index += 1
|
|
145
|
+
next
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
if stripped.start_with?("[[")
|
|
149
|
+
note, next_index = note_token_at(index, stripped)
|
|
150
|
+
if note
|
|
151
|
+
result << note
|
|
152
|
+
note_open = false
|
|
153
|
+
index = next_index
|
|
154
|
+
next
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# --- forced elements -------------------------------------------------
|
|
159
|
+
|
|
160
|
+
if stripped.match?(/\A\.[A-Za-z0-9]/)
|
|
161
|
+
body, scene_number = scene_parts(stripped[1..])
|
|
162
|
+
result << token(:scene_heading, line: lineno, text: body,
|
|
163
|
+
scene_number: scene_number, forced: true)
|
|
164
|
+
prev_blank = false
|
|
165
|
+
dialogue_open = false
|
|
166
|
+
note_open = false
|
|
167
|
+
index += 1
|
|
168
|
+
next
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
if stripped.start_with?("@") && stripped.length > 1
|
|
172
|
+
parts = character_parts(stripped[1..])
|
|
173
|
+
result << token(:character, line: lineno, **parts, forced: true)
|
|
174
|
+
prev_blank = false
|
|
175
|
+
dialogue_open = true
|
|
176
|
+
note_open = false
|
|
177
|
+
index += 1
|
|
178
|
+
next
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if stripped.start_with?("!") && stripped.length > 1
|
|
182
|
+
body = action_text(text.lstrip.sub(/\A!/, ""))
|
|
183
|
+
result << token(:action, line: lineno, text: body, forced: true)
|
|
184
|
+
prev_blank = false
|
|
185
|
+
dialogue_open = false
|
|
186
|
+
note_open = note_depth_after(body, note_open)
|
|
187
|
+
index += 1
|
|
188
|
+
next
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
if stripped.start_with?(">")
|
|
192
|
+
body = stripped[1..]
|
|
193
|
+
if body.rstrip.end_with?("<")
|
|
194
|
+
centered = body.rstrip[0...-1].strip
|
|
195
|
+
result << token(:centered, line: lineno, text: centered)
|
|
196
|
+
else
|
|
197
|
+
result << token(:transition, line: lineno, text: body.strip, forced: true)
|
|
198
|
+
end
|
|
199
|
+
prev_blank = false
|
|
200
|
+
dialogue_open = false
|
|
201
|
+
note_open = false
|
|
202
|
+
index += 1
|
|
203
|
+
next
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
if stripped.start_with?("~")
|
|
207
|
+
body = stripped[1..].strip
|
|
208
|
+
result << token(:lyrics, line: lineno, text: body)
|
|
209
|
+
prev_blank = false
|
|
210
|
+
dialogue_open = false
|
|
211
|
+
note_open = note_depth_after(body, note_open)
|
|
212
|
+
index += 1
|
|
213
|
+
next
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# --- automatic elements ----------------------------------------------
|
|
217
|
+
|
|
218
|
+
unless dialogue_open
|
|
219
|
+
if prev_blank && SCENE_PREFIX.match?(stripped)
|
|
220
|
+
body, scene_number = scene_parts(stripped)
|
|
221
|
+
result << token(:scene_heading, line: lineno, text: body,
|
|
222
|
+
scene_number: scene_number, forced: false)
|
|
223
|
+
prev_blank = false
|
|
224
|
+
note_open = false
|
|
225
|
+
index += 1
|
|
226
|
+
next
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
if prev_blank && transition_line?(text)
|
|
230
|
+
result << token(:transition, line: lineno, text: stripped, forced: false)
|
|
231
|
+
prev_blank = false
|
|
232
|
+
note_open = false
|
|
233
|
+
index += 1
|
|
234
|
+
next
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
if prev_blank && character_line?(stripped, lines[index + 1])
|
|
238
|
+
parts = character_parts(stripped)
|
|
239
|
+
result << token(:character, line: lineno, **parts, forced: false)
|
|
240
|
+
prev_blank = false
|
|
241
|
+
dialogue_open = true
|
|
242
|
+
note_open = false
|
|
243
|
+
index += 1
|
|
244
|
+
next
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
if dialogue_open
|
|
249
|
+
result << if stripped.start_with?("(") && stripped.end_with?(")")
|
|
250
|
+
token(:parenthetical, line: lineno, text: stripped)
|
|
251
|
+
else
|
|
252
|
+
token(:dialogue, line: lineno, text: stripped)
|
|
253
|
+
end
|
|
254
|
+
note_open = note_depth_after(stripped, note_open)
|
|
255
|
+
prev_blank = false
|
|
256
|
+
index += 1
|
|
257
|
+
next
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
body = action_text(text)
|
|
261
|
+
result << token(:action, line: lineno, text: body)
|
|
262
|
+
note_open = note_depth_after(body, note_open)
|
|
263
|
+
prev_blank = false
|
|
264
|
+
index += 1
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
result
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# A single-line note, or a note spanning consecutive lines. Returns nil
|
|
271
|
+
# when the brackets are not a note (unterminated, followed by extra text,
|
|
272
|
+
# or interrupted by a blank line).
|
|
273
|
+
def note_token_at(index, stripped)
|
|
274
|
+
if (close = stripped.index("]]"))
|
|
275
|
+
trailing = stripped[(close + 2)..].to_s.strip
|
|
276
|
+
return nil unless trailing.empty?
|
|
277
|
+
|
|
278
|
+
return [token(:note, line: index + 1, text: stripped[2...close].strip), index + 1]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
collected = [stripped[2..]]
|
|
282
|
+
cursor = index + 1
|
|
283
|
+
|
|
284
|
+
while cursor < lines.size
|
|
285
|
+
line = lines[cursor]
|
|
286
|
+
|
|
287
|
+
if line.strip.empty?
|
|
288
|
+
if line.match?(HARD_BREAK)
|
|
289
|
+
# A line of two or more spaces keeps the note connected while
|
|
290
|
+
# adding an intentional blank line.
|
|
291
|
+
collected << ""
|
|
292
|
+
cursor += 1
|
|
293
|
+
next
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
return nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
if (close = line.index("]]"))
|
|
300
|
+
return nil unless line[(close + 2)..].to_s.strip.empty?
|
|
301
|
+
|
|
302
|
+
collected << line[0...close]
|
|
303
|
+
content = collected.map(&:strip).join("\n").strip
|
|
304
|
+
return [token(:note, line: index + 1, text: content), cursor + 1]
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
collected << line
|
|
308
|
+
cursor += 1
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
nil
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def scene_parts(text)
|
|
315
|
+
scene_number = nil
|
|
316
|
+
body = text
|
|
317
|
+
|
|
318
|
+
if (match = SCENE_NUMBER.match(body))
|
|
319
|
+
scene_number = match[1]
|
|
320
|
+
body = body[0...match.begin(0)]
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
[body.rstrip, scene_number]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def character_parts(text)
|
|
327
|
+
cue = text.rstrip
|
|
328
|
+
dual = false
|
|
329
|
+
|
|
330
|
+
if cue.end_with?("^")
|
|
331
|
+
dual = true
|
|
332
|
+
cue = cue[0...-1].rstrip
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
extensions = []
|
|
336
|
+
while (match = CHARACTER_EXTENSION.match(cue))
|
|
337
|
+
extensions.unshift(match[1].strip)
|
|
338
|
+
cue = cue[0...match.begin(0)].rstrip
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
{name: cue, extensions: extensions, dual: dual}
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def character_line?(stripped, next_line)
|
|
345
|
+
return false if next_line.nil?
|
|
346
|
+
return false if next_line.strip.empty?
|
|
347
|
+
|
|
348
|
+
name = character_parts(stripped)[:name]
|
|
349
|
+
return false if name.empty?
|
|
350
|
+
return false unless name.match?(/[A-Za-z]/)
|
|
351
|
+
|
|
352
|
+
name == name.upcase
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def transition_line?(text)
|
|
356
|
+
cue = text.lstrip
|
|
357
|
+
|
|
358
|
+
# Spaces after the colon deliberately make a transition an Action line.
|
|
359
|
+
cue.end_with?("TO:") && cue.match?(/[A-Za-z]/) && cue == cue.upcase
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Track whether a multi-line action/dialogue run currently has an
|
|
363
|
+
# unclosed note. Two-space blank lines are allowed inside such a run, so
|
|
364
|
+
# this lets the tokenizer keep the note connected.
|
|
365
|
+
def note_depth_after(text, currently_open)
|
|
366
|
+
open = currently_open
|
|
367
|
+
index = 0
|
|
368
|
+
|
|
369
|
+
while index < text.length
|
|
370
|
+
if text[index] == "\\"
|
|
371
|
+
index += 2
|
|
372
|
+
elsif text[index, 2] == "[["
|
|
373
|
+
open = true
|
|
374
|
+
index += 2
|
|
375
|
+
elsif text[index, 2] == "]]"
|
|
376
|
+
open = false
|
|
377
|
+
index += 2
|
|
378
|
+
else
|
|
379
|
+
index += 1
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
open
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def action_text(text)
|
|
387
|
+
text.gsub("\t", " ").rstrip
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FountainRB
|
|
4
|
+
# A problem found while validating Fountain source.
|
|
5
|
+
#
|
|
6
|
+
# Validation is about likely mistakes, not about rejecting input: the
|
|
7
|
+
# parser itself always succeeds and falls back to Action for anything it
|
|
8
|
+
# does not understand (Fountain's own error-handling philosophy).
|
|
9
|
+
class Issue
|
|
10
|
+
SEVERITIES = %i[error warning].freeze
|
|
11
|
+
|
|
12
|
+
# @return [Symbol] a short machine-readable code, e.g. :unclosed_note
|
|
13
|
+
attr_reader :code
|
|
14
|
+
|
|
15
|
+
# @return [String] a human-readable description
|
|
16
|
+
attr_reader :message
|
|
17
|
+
|
|
18
|
+
# @return [Integer] 1-based source line
|
|
19
|
+
attr_reader :line
|
|
20
|
+
|
|
21
|
+
# @return [Symbol] :error or :warning
|
|
22
|
+
attr_reader :severity
|
|
23
|
+
|
|
24
|
+
def initialize(code:, message:, line:, severity: :error)
|
|
25
|
+
raise ArgumentError, "unknown severity: #{severity.inspect}" unless SEVERITIES.include?(severity)
|
|
26
|
+
|
|
27
|
+
@code = code.to_sym
|
|
28
|
+
@message = message.to_s
|
|
29
|
+
@line = line
|
|
30
|
+
@severity = severity.to_sym
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def error?
|
|
34
|
+
severity == :error
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def warning?
|
|
38
|
+
severity == :warning
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Hash] a JSON-friendly representation
|
|
42
|
+
def to_h
|
|
43
|
+
{
|
|
44
|
+
"code" => code.to_s,
|
|
45
|
+
"message" => message,
|
|
46
|
+
"line" => line,
|
|
47
|
+
"severity" => severity.to_s
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_s
|
|
52
|
+
"line #{line}: #{message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ==(other)
|
|
56
|
+
other.is_a?(Issue) && other.to_h == to_h
|
|
57
|
+
end
|
|
58
|
+
alias_method :eql?, :==
|
|
59
|
+
|
|
60
|
+
def hash
|
|
61
|
+
to_h.hash
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def inspect
|
|
65
|
+
"#<FountainRB::Issue #{to_h.inspect}>"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Checks Fountain source for likely mistakes.
|
|
70
|
+
#
|
|
71
|
+
# FountainRB.validate(source) #=> [#<Issue ...>, ...]
|
|
72
|
+
# FountainRB.valid?(source) #=> true / false
|
|
73
|
+
#
|
|
74
|
+
# The validator looks for problems that the permissive parser would
|
|
75
|
+
# otherwise silently recover from:
|
|
76
|
+
#
|
|
77
|
+
# * an unclosed boneyard (+/*+) or note (+[[+)
|
|
78
|
+
# * a note that spans a blank line (which prevents it from being a note)
|
|
79
|
+
# * closing brackets or boneyards without a matching opener
|
|
80
|
+
# * a scene number missing its closing +#+
|
|
81
|
+
# * a dual-dialogue caret with no preceding dialogue block
|
|
82
|
+
#
|
|
83
|
+
# Use {Issue#severity} to distinguish errors from warnings, and
|
|
84
|
+
# {Issue#line} to point at the source line.
|
|
85
|
+
class Validator
|
|
86
|
+
BONEYARD_PATTERN = %r{/\*.*?\*/}m
|
|
87
|
+
CONNECTED_BLANK = /\A[ \t]{2,}\z/
|
|
88
|
+
MALFORMED_SCENE_NUMBER = /#[A-Za-z0-9._-]+[ \t]*\z/
|
|
89
|
+
VALID_SCENE_NUMBER = /#[A-Za-z0-9._-]+#[ \t]*\z/
|
|
90
|
+
|
|
91
|
+
# @return [String] the normalised source being validated
|
|
92
|
+
attr_reader :source
|
|
93
|
+
|
|
94
|
+
# @param source [String, nil]
|
|
95
|
+
# @param screenplay [Screenplay, nil] an already parsed screenplay for
|
|
96
|
+
# +source+ (avoids parsing twice)
|
|
97
|
+
def initialize(source, screenplay: nil)
|
|
98
|
+
@source = normalise(source)
|
|
99
|
+
@screenplay = screenplay
|
|
100
|
+
@issues = []
|
|
101
|
+
@validated = false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @return [Array<Issue>] every issue, ordered by line
|
|
105
|
+
def validate
|
|
106
|
+
return @issues if @validated
|
|
107
|
+
|
|
108
|
+
@validated = true
|
|
109
|
+
check_boneyards
|
|
110
|
+
check_notes
|
|
111
|
+
check_scene_numbers
|
|
112
|
+
check_dual_dialogue
|
|
113
|
+
@issues.sort_by! { |issue| [issue.line, issue.code.to_s] }
|
|
114
|
+
@issues
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# True when validation found nothing at all.
|
|
118
|
+
#
|
|
119
|
+
# @return [Boolean]
|
|
120
|
+
def valid?
|
|
121
|
+
validate.empty?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Array<Issue>] only the errors
|
|
125
|
+
def errors
|
|
126
|
+
validate.select(&:error?)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @return [Array<Issue>] only the warnings
|
|
130
|
+
def warnings
|
|
131
|
+
validate.select(&:warning?)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def normalise(source)
|
|
137
|
+
source.to_s.sub(/\A\uFEFF/, "").gsub(/\r\n?/, "\n")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def lines
|
|
141
|
+
@lines ||= source.split("\n", -1)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def screenplay
|
|
145
|
+
@screenplay ||= Parser.new(source).parse
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def add_issue(code, message, line, severity)
|
|
149
|
+
@issues << Issue.new(code: code, message: message, line: line, severity: severity)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Source with closed boneyards removed, keeping line numbers stable.
|
|
153
|
+
def boneyard_stripped_source
|
|
154
|
+
@boneyard_stripped_source ||= source.gsub(BONEYARD_PATTERN) do |match|
|
|
155
|
+
"\n" * match.count("\n")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Source with boneyards removed and an unterminated boneyard (which
|
|
160
|
+
# comments out the rest of the file) truncated.
|
|
161
|
+
def scan_source
|
|
162
|
+
@scan_source ||= begin
|
|
163
|
+
cleaned = boneyard_stripped_source
|
|
164
|
+
open = cleaned.index("/*")
|
|
165
|
+
open ? cleaned[0...open] : cleaned
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def scan_lines
|
|
170
|
+
@scan_lines ||= scan_source.split("\n", -1)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def check_boneyards
|
|
174
|
+
remaining = boneyard_stripped_source
|
|
175
|
+
|
|
176
|
+
if (open = remaining.index("/*"))
|
|
177
|
+
add_issue(
|
|
178
|
+
:unclosed_boneyard,
|
|
179
|
+
"Boneyard opened with /* is never closed",
|
|
180
|
+
line_at(remaining, open),
|
|
181
|
+
:error
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
if (close = remaining.index("*/"))
|
|
186
|
+
add_issue(
|
|
187
|
+
:unexpected_boneyard_close,
|
|
188
|
+
"Boneyard closing */ has no matching /*",
|
|
189
|
+
line_at(remaining, close),
|
|
190
|
+
:warning
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def check_notes
|
|
196
|
+
open_line = nil
|
|
197
|
+
crossed_blank = false
|
|
198
|
+
|
|
199
|
+
scan_lines.each_with_index do |line, index|
|
|
200
|
+
lineno = index + 1
|
|
201
|
+
|
|
202
|
+
if line.strip.empty?
|
|
203
|
+
crossed_blank = true if open_line && !line.match?(CONNECTED_BLANK)
|
|
204
|
+
next
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
position = 0
|
|
208
|
+
while position < line.length
|
|
209
|
+
if line[position] == "\\"
|
|
210
|
+
if line[position + 1, 2] == "[["
|
|
211
|
+
# An escaped opener makes the whole construct literal text, so
|
|
212
|
+
# skip its closing brackets too.
|
|
213
|
+
close = line.index("]]", position + 3)
|
|
214
|
+
position = close ? close + 2 : line.length
|
|
215
|
+
else
|
|
216
|
+
position += 2
|
|
217
|
+
end
|
|
218
|
+
elsif line[position, 2] == "[["
|
|
219
|
+
if open_line.nil?
|
|
220
|
+
open_line = lineno
|
|
221
|
+
crossed_blank = false
|
|
222
|
+
end
|
|
223
|
+
position += 2
|
|
224
|
+
elsif line[position, 2] == "]]"
|
|
225
|
+
if open_line.nil?
|
|
226
|
+
add_issue(
|
|
227
|
+
:unexpected_note_close,
|
|
228
|
+
"Note closing brackets ]] have no matching [[",
|
|
229
|
+
lineno,
|
|
230
|
+
:warning
|
|
231
|
+
)
|
|
232
|
+
else
|
|
233
|
+
if crossed_blank
|
|
234
|
+
add_issue(
|
|
235
|
+
:note_spans_blank_line,
|
|
236
|
+
"Note opened on line #{open_line} spans a blank line and will not be parsed as a note",
|
|
237
|
+
open_line,
|
|
238
|
+
:warning
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
open_line = nil
|
|
242
|
+
crossed_blank = false
|
|
243
|
+
end
|
|
244
|
+
position += 2
|
|
245
|
+
else
|
|
246
|
+
position += 1
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
return unless open_line
|
|
252
|
+
|
|
253
|
+
add_issue(
|
|
254
|
+
:unclosed_note,
|
|
255
|
+
"Note opened with [[ is never closed",
|
|
256
|
+
open_line,
|
|
257
|
+
:error
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def check_scene_numbers
|
|
262
|
+
screenplay.scenes.each do |scene|
|
|
263
|
+
line = lines[scene.line - 1]
|
|
264
|
+
next if line.nil?
|
|
265
|
+
next if line.match?(VALID_SCENE_NUMBER)
|
|
266
|
+
next unless line.match?(MALFORMED_SCENE_NUMBER)
|
|
267
|
+
|
|
268
|
+
add_issue(
|
|
269
|
+
:malformed_scene_number,
|
|
270
|
+
"Scene number is missing its closing #",
|
|
271
|
+
scene.line,
|
|
272
|
+
:warning
|
|
273
|
+
)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def check_dual_dialogue
|
|
278
|
+
screenplay.units.each do |unit|
|
|
279
|
+
next unless unit.is_a?(DialogueBlock) && unit.dual?
|
|
280
|
+
|
|
281
|
+
add_issue(
|
|
282
|
+
:orphan_dual_dialogue,
|
|
283
|
+
"Dual-dialogue caret has no preceding dialogue block",
|
|
284
|
+
unit.character.line,
|
|
285
|
+
:warning
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def line_at(text, index)
|
|
291
|
+
text[0...index].count("\n") + 1
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|