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.
@@ -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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FountainRB
4
+ # Semantic version of the fountainrb gem.
5
+ VERSION = "1.0.0"
6
+ end