ninja_manifest 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f855fde81252e265a37c914cf042a3e3a7a3451f75a36355665e365a9269419d
4
+ data.tar.gz: 3bad76faf0a801c1036ce7e7d8fc0cf5742266898c6be3afe71d6f9d52a9cfd1
5
+ SHA512:
6
+ metadata.gz: 9fd4be559c1a411c77747516bb37bcd9946451fb2d8a1b6aa08c30ce6c399cb298ab68b4f09876ff98fc84c95f81aef9409b4461a87f0fe84b156eb34772e5d8
7
+ data.tar.gz: 3c05281ab962527d0101b9de91266d88f3ce9ef246fbcdd148c13267898bfc20bbdb1652770d6f8681b0868f95fcff0c8ab2d8ac51b2cc1ad0f46b7eb77fb7e7
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "syntax_tree", "~> 6.3"
6
+ gem "test-unit", "~> 3.6"
7
+ gem "simplecov", "~> 0.22.0"
8
+ gem "simplecov-lcov", "~> 0.9.0"
9
+ gem "rdoc", "~> 6.15"
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Yuta Saito
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # NinjaManifest
2
+
3
+ A Ruby toolkit for parsing and evaluating [Ninja build manifest](https://ninja-build.org/manual.html) files (`build.ninja`).
4
+
5
+ Features:
6
+
7
+ * Parses Ninja build manifest files according to the official [Ninja implementation](https://ninja-build.org/)
8
+ * Evaluates variable expansions and rule bindings
9
+ * Visitor pattern API for flexible processing
10
+ * Compact and plain old pure Ruby implementation without external dependencies
11
+
12
+ ## Installation
13
+
14
+ Install the gem and add to the application's Gemfile by executing:
15
+
16
+ $ bundle add ninja_manifest
17
+
18
+ If bundler is not being used to manage dependencies, install the gem by executing:
19
+
20
+ $ gem install ninja_manifest
21
+
22
+ ## Basic Usage
23
+
24
+ ```ruby
25
+ require "ninja_manifest"
26
+
27
+ # Parse and evaluate a build.ninja content
28
+ manifest = NinjaManifest.load(<<~NINJA)
29
+ cxx = c++
30
+ builddir = build
31
+
32
+ rule cxx
33
+ command = $cxx -c $in -o $out
34
+ description = CXX $out
35
+
36
+ build $builddir/main.o: cxx src/main.cc
37
+ cxx = clang
38
+ NINJA
39
+ # Or load from build.ninja file
40
+ manifest = NinjaManifest.load_file("build.ninja")
41
+
42
+ # Access parsed data
43
+
44
+ manifest.variables # Variables hash
45
+ manifest.variables["cxx"] # => "c++"
46
+
47
+ manifest.rules # Rules hash
48
+ rule = manifest.rules["cxx"]
49
+ rule["command"] # => "$cxx -c $in -o $out"
50
+ rule["description"] # => "CXX $out"
51
+
52
+ build = manifest.builds.first
53
+ build[:outputs][:explicit] # => ["build/main.o"]
54
+ build[:inputs][:explicit] # => ["src/main.cc"]
55
+ build[:vars]["command"] # => "c++ -c src/main.cc -o build/main.o"
56
+ build[:vars]["description"] # => "CXX build/main.o"
57
+ ```
58
+
59
+ ## Custom Visitor Usage
60
+
61
+ ```ruby
62
+ require "ninja_manifest"
63
+
64
+ # Create a custom visitor to process parsed constructs
65
+ class MyVisitor < NinjaManifest::Visitor
66
+ def visit_variable(name:, value:)
67
+ puts "Variable: #{name} = #{value}"
68
+ end
69
+
70
+ def visit_rule(name:, vars:)
71
+ puts "Rule: #{name} with #{vars.keys.size} attributes"
72
+ end
73
+
74
+ def visit_build(rule:, outs:, ins:, vars:, **kwargs)
75
+ puts "Build: #{rule} -> #{outs[:explicit].join(', ')}"
76
+ end
77
+ end
78
+
79
+ visitor = MyVisitor.new
80
+ File.open("build.ninja", "r") do |f|
81
+ NinjaManifest::parse(f.read, visitor)
82
+ end
83
+ ```
84
+
85
+ ## Contributing
86
+
87
+ Bug reports and pull requests are welcome on GitHub at https://github.com/kateinoigakukun/ninja_manifest.
88
+
89
+ ## License
90
+
91
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
92
+
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+ require "rdoc/task"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.libs << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ end
10
+
11
+ Rake::RDocTask.new do |rdoc|
12
+ rdoc.rdoc_files.add(%w[README.md LICENSE lib/ninja_manifest.rb])
13
+ rdoc.main = "README.md"
14
+ rdoc.title = "ninja_parser Docs"
15
+ rdoc.rdoc_dir = "doc"
16
+ end
@@ -0,0 +1,986 @@
1
+ # MIT License
2
+ #
3
+ # Copyright (c) 2025 Yuta Saito
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+
23
+ ##
24
+ # The +NinjaManifest+ module provides a simple yet robust mechanism for parsing and
25
+ # evaluating build manifests written for the Ninja build system. It expands variables, resolves rule
26
+ # bindings, and constructs a fully-evaluated manifest representation suitable for inspection or custom
27
+ # tooling.
28
+ #
29
+ # == What’s Here
30
+ #
31
+ # - +NinjaManifest.load+ and +NinjaManifest.load_file+: entry-points for parsing/evaluating.
32
+ # - +NinjaManifest::parse+: flexible visitor-based parsing API without evaluation.
33
+ # - +NinjaManifest::Manifest+: the evaluated manifest (variables, rules, builds, etc.).
34
+ # - +NinjaManifest::Visitor+: base visitor class for parsed constructs.
35
+ #
36
+ module NinjaManifest
37
+ VERSION = "0.1.0"
38
+
39
+ ##
40
+ # call-seq:
41
+ #
42
+ # load(input) -> Manifest
43
+ #
44
+ # Parses and evaluates a Ninja build manifest, expanding all variables and resolving rule bindings.
45
+ #
46
+ # All variable references like $foo and ${bar} are expanded, and build bindings are resolved
47
+ # according to Ninja's scoping rules.
48
+ #
49
+ # manifest = NinjaManifest.load(<<~NINJA)
50
+ # cxx = c++
51
+ # builddir = build
52
+ #
53
+ # rule cxx
54
+ # command = $cxx -c $in -o $out
55
+ # description = CXX $out
56
+ #
57
+ # build $builddir/main.o: cxx src/main.cc
58
+ # cxx = clang
59
+ # NINJA
60
+ #
61
+ # manifest.variables["cxx"] # => "c++"
62
+ # manifest.rules["cxx"] # => {"command" => "$cxx -c $in -o $out", "description" => "CXX $out"}
63
+ #
64
+ # manifest.builds.each do |build|
65
+ # puts build[:outputs][:explicit] # => ["build/main.o"]
66
+ # puts build[:command] # => "c++ -c src/main.cc -o build/main.o"
67
+ # end
68
+ #
69
+ # The input is a String (manifest content). To load from a file path, use NinjaManifest.load_file instead.
70
+ def self.load(input, **opts)
71
+ evaluator = Evaluator.new(**opts)
72
+ parse(input, evaluator)
73
+ evaluator.finalize
74
+ end
75
+
76
+ ##
77
+ # call-seq:
78
+ #
79
+ # load_file(path) -> Manifest
80
+ #
81
+ # Loads and evaluates a Ninja build manifest from a file path.
82
+ #
83
+ # This is a convenience method that reads the file and calls load.
84
+ #
85
+ # manifest = NinjaManifest.load_file("build.ninja")
86
+ # manifest.variables["cc"] # => "gcc"
87
+ # manifest.builds.size # => 42
88
+ def self.load_file(path, **opts)
89
+ File.open(path, "r") { |file| load(file.read, **opts) }
90
+ end
91
+
92
+ ##
93
+ # Represents an evaluated Ninja build manifest.
94
+ #
95
+ # A Manifest contains all parsed and evaluated data from a +build.ninja+ file,
96
+ # including expanded variables, rules, builds, and etc.
97
+ #
98
+ # manifest = NinjaManifest.load_file("build.ninja")
99
+ # manifest.variables["cc"] # => "gcc"
100
+ # manifest.rules["cc"] # => {"command" => "gcc $in -o $out", ...}
101
+ # manifest.builds.first # => {:rule => "cc", :outputs => {...}, ...}
102
+ # manifest.defaults # => ["app"]
103
+ #
104
+ # You typically create a Manifest using NinjaManifest.load_file or NinjaManifest.load.
105
+ class Manifest
106
+ ##
107
+ # Returns the global variables hash with all values expanded.
108
+ #
109
+ # manifest.variables["cc"] # => "gcc"
110
+ # manifest.variables["cflags"] # => "-Wall -O2"
111
+ attr_reader :variables
112
+
113
+ ##
114
+ # Returns a hash of rule definitions, keyed by rule name.
115
+ #
116
+ # Each rule is a hash of attribute names to their (raw, unevaluated) values.
117
+ #
118
+ # manifest.rules["cc"]
119
+ # # => {
120
+ # # "command" => "gcc $in -o $out",
121
+ # # "description" => "CC $out",
122
+ # # ...
123
+ # # }
124
+ attr_reader :rules
125
+
126
+ ##
127
+ # Returns an array of build records.
128
+ #
129
+ # Each record is a hash with the following keys:
130
+ # - +:rule+: the rule name used for this build
131
+ # - +:outputs+: hash with +:explicit+ and +:implicit+ arrays (expanded paths)
132
+ # - +:inputs+: hash with +:explicit+, +:implicit+, +:order_only+, +:validation+ arrays (expanded paths)
133
+ # - +:vars+: hash of build-local variables (expanded)
134
+ #
135
+ # Example:
136
+ #
137
+ # build = manifest.builds.first
138
+ # build[:outputs][:explicit] # => ["build/main.o"]
139
+ # build[:inputs][:explicit] # => ["src/main.cc"]
140
+ attr_reader :builds
141
+
142
+ ##
143
+ # Returns an array of default target paths (fully expanded).
144
+ #
145
+ # manifest.defaults # => ["all", "tests"]
146
+ attr_reader :defaults
147
+
148
+ ##
149
+ # Returns a hash of pool definitions, keyed by pool name.
150
+ #
151
+ # Each pool is a hash of attribute names to their values.
152
+ #
153
+ # manifest.pools["link"]
154
+ # # => { "depth" => "1" }
155
+ attr_reader :pools
156
+
157
+ def initialize(variables:, rules:, builds:, defaults:, pools:) # :nodoc:
158
+ @variables = variables
159
+ @rules = rules
160
+ @builds = builds
161
+ @defaults = defaults
162
+ @pools = pools
163
+ end
164
+ end
165
+
166
+ ##
167
+ # call-seq:
168
+ #
169
+ # parse(input, visitor) -> nil
170
+ #
171
+ # Parses a Ninja build manifest and invokes callbacks on the provided Visitor object.
172
+ #
173
+ # The input is a String (manifest content). The visitor receives callbacks for all parsed constructs,
174
+ # see Visitor for more details.
175
+ #
176
+ # class MyVisitor < NinjaManifest::Visitor
177
+ # def visit_build(rule:, outs:, ins:, vars:, **kwargs)
178
+ # puts "Build: #{rule} -> #{outs[:explicit].join(', ')}"
179
+ # end
180
+ # end
181
+ #
182
+ # visitor = MyVisitor.new
183
+ # NinjaManifest.parse(<<~NINJA, visitor)
184
+ # cxx = c++
185
+ # builddir = build
186
+ #
187
+ # rule cxx
188
+ # command = $cxx -c $in -o $out
189
+ # description = CXX $out
190
+ #
191
+ # build $builddir/main.o: cxx src/main.cc
192
+ # cxx = clang
193
+ # NINJA
194
+ def self.parse(input, visitor)
195
+ parser = Parser.new(input)
196
+ parser.parse(visitor)
197
+ end
198
+
199
+ ##
200
+ # Base visitor class for processing parsed Ninja manifest constructs.
201
+ #
202
+ # Subclass this class and override the visit methods to customize how parsed elements
203
+ # are processed. All methods have default no-op implementations, so you only need to
204
+ # override the ones you care about.
205
+ #
206
+ # class MyVisitor < NinjaManifest::Visitor
207
+ # def visit_build(rule:, outs:, ins:, vars:, **kwargs)
208
+ # puts "Build: #{rule} -> #{outs[:explicit].join(', ')}"
209
+ # end
210
+ # end
211
+ #
212
+ # visitor = MyVisitor.new
213
+ # NinjaManifest.parse(<<~NINJA, visitor)
214
+ # cxx = c++
215
+ # builddir = build
216
+ #
217
+ # rule cxx
218
+ # command = $cxx -c $in -o $out
219
+ # description = CXX $out
220
+ #
221
+ # build $builddir/main.o: cxx src/main.cc
222
+ # cxx = clang
223
+ # NINJA
224
+ class Visitor
225
+ ##
226
+ # Called when a variable assignment is encountered.
227
+ #
228
+ # The value is the raw (unevaluated) string, which may contain variable references
229
+ # like $foo or ${bar}. Variable expansion is not performed at parse time.
230
+ #
231
+ # def visit_variable(name:, value:)
232
+ # puts "#{name} = #{value}" # value may be "$cc -Wall" or similar
233
+ # end
234
+ def visit_variable(name:, value:)
235
+ end
236
+
237
+ ##
238
+ # Called when a rule definition is encountered.
239
+ #
240
+ # Rule attributes are raw strings and may contain variable references.
241
+ # Common attributes include +"command"+, +"description"+, +"depfile"+, etc.
242
+ #
243
+ # def visit_rule(name:, vars:)
244
+ # puts "Rule: #{name}"
245
+ # puts "Command: #{vars["command"]}" # may contain "$in $out"
246
+ # end
247
+ def visit_rule(name:, vars:)
248
+ end
249
+
250
+ ##
251
+ # Called when a build statement is encountered.
252
+ #
253
+ # The +outs+ hash contains output paths with keys:
254
+ # - +:explicit+: explicit output paths (before |)
255
+ # - +:implicit+: implicit output paths (after |)
256
+ #
257
+ # The +ins+ hash contains input paths with keys:
258
+ # - +:explicit+: explicit dependencies (before |)
259
+ # - +:implicit+: implicit dependencies (after |)
260
+ # - +:order_only+: order-only dependencies (after ||)
261
+ # - +:validation+: validation dependencies (after @)
262
+ #
263
+ # The +rule+ parameter is the rule name, or an empty string for implicit phony builds.
264
+ # The +vars+ hash contains build-local variable assignments (raw, unevaluated).
265
+ #
266
+ # Example:
267
+ #
268
+ # def visit_build(rule:, outs:, ins:, vars:, **kwargs)
269
+ # puts "Building #{outs[:explicit].join(', ')} using rule #{rule}"
270
+ # puts "Dependencies: #{ins[:explicit].join(', ')}"
271
+ # end
272
+ def visit_build(rule:, outs:, ins:, vars:, **kwargs)
273
+ end
274
+
275
+ ##
276
+ # Called when a default statement is encountered.
277
+ #
278
+ # The targets array contains target paths (raw, unevaluated) that are marked
279
+ # as default build targets.
280
+ #
281
+ # def visit_default(targets:)
282
+ # puts "Default targets: #{targets.join(', ')}"
283
+ # end
284
+ def visit_default(targets:)
285
+ end
286
+
287
+ ##
288
+ # Called when an include statement is encountered.
289
+ #
290
+ # The path is the path to the included file (raw, unevaluated).
291
+ #
292
+ # def visit_include(path:)
293
+ # puts "Including file: #{path}"
294
+ # end
295
+ def visit_include(path:)
296
+ end
297
+
298
+ ##
299
+ # Called when a subninja statement is encountered.
300
+ #
301
+ # The path is the path to the subninja file (raw, unevaluated).
302
+ #
303
+ # def visit_subninja(path:)
304
+ # puts "Subninja file: #{path}"
305
+ # end
306
+ def visit_subninja(path:)
307
+ end
308
+
309
+ ##
310
+ # Called when a pool statement is encountered.
311
+ #
312
+ # The name is the name of the pool (raw, unevaluated).
313
+ #
314
+ # def visit_pool(name:, vars:)
315
+ # puts "Pool: #{name} = #{vars}"
316
+ # end
317
+ def visit_pool(name:, vars:)
318
+ end
319
+ end
320
+
321
+ # Error raised when evaluation encounters invalid data.
322
+ Error = Class.new(StandardError)
323
+
324
+ # Scanner for character-by-character parsing of input text.
325
+ class Scanner # :nodoc:
326
+ attr_reader :offset, :line
327
+
328
+ def initialize(buffer)
329
+ @buffer = buffer.force_encoding("utf-8")
330
+ @offset = 0
331
+ @line = 1
332
+ end
333
+
334
+ def peek
335
+ return "\0" if @offset >= @buffer.length
336
+
337
+ c = @buffer[@offset]
338
+ if c == "\r" && @offset + 1 < @buffer.length &&
339
+ @buffer[@offset + 1] == "\n"
340
+ "\n"
341
+ else
342
+ c
343
+ end
344
+ end
345
+
346
+ def next
347
+ read
348
+ end
349
+
350
+ def back
351
+ raise Error, "back at start" if @offset == 0
352
+
353
+ @offset -= 1
354
+ if @offset >= 0 && @buffer[@offset] == "\n"
355
+ @line -= 1
356
+ elsif @offset > 0 && @buffer[@offset - 1] == "\r" &&
357
+ @buffer[@offset] == "\n"
358
+ @offset -= 1
359
+ @line -= 1
360
+ end
361
+ end
362
+
363
+ def read
364
+ return "\0" if @offset >= @buffer.length
365
+
366
+ c = @buffer[@offset]
367
+ if c == "\r" && @offset + 1 < @buffer.length &&
368
+ @buffer[@offset + 1] == "\n"
369
+ @offset += 2
370
+ @line += 1
371
+ return "\n"
372
+ end
373
+
374
+ @offset += 1
375
+ @line += 1 if c == "\n"
376
+ c
377
+ end
378
+
379
+ def skip(ch)
380
+ if read != ch
381
+ back
382
+ return false
383
+ end
384
+ true
385
+ end
386
+
387
+ def skip_spaces
388
+ while skip(" ")
389
+ end
390
+ end
391
+
392
+ def expect(ch)
393
+ r = read
394
+ if r != ch
395
+ back
396
+ raise Error, "expected #{ch.inspect}, got #{r.inspect}"
397
+ end
398
+ end
399
+
400
+ def slice(start, ending)
401
+ @buffer[start...ending]
402
+ end
403
+ end
404
+
405
+ private_constant :Scanner
406
+
407
+ ##
408
+ # Parser for Ninja build manifest files.
409
+ #
410
+ # Parses build.ninja files according to the Ninja manifest format specification,
411
+ # handling continuations, variable expansions, and all statement types. The parser
412
+ # uses a visitor pattern to deliver parsed constructs.
413
+ class Parser # :nodoc:
414
+ ##
415
+ # Creates a parser for the given input source.
416
+ def initialize(input)
417
+ @scanner = Scanner.new(input)
418
+ end
419
+
420
+ ##
421
+ # Parses the manifest and invokes visitor callbacks for each parsed construct.
422
+ def parse(visitor)
423
+ loop do
424
+ case @scanner.peek
425
+ when "\0"
426
+ break
427
+ when "\n"
428
+ @scanner.next
429
+ when "#"
430
+ skip_comment
431
+ when " ", "\t"
432
+ raise Error, "unexpected whitespace"
433
+ else
434
+ ident = read_ident
435
+ skip_spaces
436
+ case ident
437
+ when "rule"
438
+ visitor.visit_rule(**read_rule)
439
+ when "build"
440
+ visitor.visit_build(**read_build)
441
+ when "default"
442
+ visitor.visit_default(**read_default)
443
+ when "include"
444
+ path = read_eval(false)
445
+ visitor.visit_include(path: path)
446
+ when "subninja"
447
+ path = read_eval(false)
448
+ visitor.visit_subninja(path: path)
449
+ when "pool"
450
+ visitor.visit_pool(**read_pool)
451
+ else
452
+ # Variable assignment
453
+ val = read_vardef
454
+ visitor.visit_variable(name: ident, value: val)
455
+ end
456
+ end
457
+ end
458
+ end
459
+
460
+ private
461
+
462
+ def skip_comment
463
+ loop do
464
+ case @scanner.read
465
+ when "\0"
466
+ @scanner.back
467
+ break
468
+ when "\n"
469
+ break
470
+ end
471
+ end
472
+ end
473
+
474
+ def read_ident
475
+ start = @scanner.offset
476
+ while (c = @scanner.read)
477
+ break unless c.match?(/[a-zA-Z0-9_._-]/)
478
+ break if c == "\0"
479
+ end
480
+ @scanner.back
481
+ ending = @scanner.offset
482
+ raise Error, "failed to scan ident" if ending == start
483
+ @scanner.slice(start, ending)
484
+ end
485
+
486
+ def read_vardef
487
+ skip_spaces
488
+ @scanner.expect("=")
489
+ skip_spaces
490
+ if @scanner.peek == "\n"
491
+ @scanner.expect("\n")
492
+ return ""
493
+ end
494
+ result = read_eval(false)
495
+ @scanner.expect("\n")
496
+ result
497
+ end
498
+
499
+ def read_scoped_vars(variable_name_validator: nil)
500
+ vars = {}
501
+ while @scanner.peek == " "
502
+ skip_spaces
503
+ name = read_ident
504
+ if variable_name_validator && !variable_name_validator.call(name)
505
+ raise Error, "unexpected variable #{name.inspect}"
506
+ end
507
+ skip_spaces
508
+ val = read_vardef
509
+ vars[name] = val
510
+ end
511
+ vars
512
+ end
513
+
514
+ def read_rule
515
+ name = read_ident
516
+ @scanner.expect("\n")
517
+ validator =
518
+ lambda do |var|
519
+ %w[
520
+ command
521
+ depfile
522
+ dyndep
523
+ description
524
+ deps
525
+ generator
526
+ pool
527
+ restat
528
+ rspfile
529
+ rspfile_content
530
+ msvc_deps_prefix
531
+ hide_success
532
+ hide_progress
533
+ ].include?(var)
534
+ end
535
+ vars = read_scoped_vars(variable_name_validator: validator)
536
+ { name: name, vars: vars }
537
+ end
538
+
539
+ def read_pool
540
+ name = read_ident
541
+ @scanner.expect("\n")
542
+ validator = lambda { |var| var == "depth" }
543
+ vars = read_scoped_vars(variable_name_validator: validator)
544
+ { name: name, vars: vars }
545
+ end
546
+
547
+ def read_unevaluated_paths_to(stop_at_path_sep: true)
548
+ skip_spaces
549
+ v = []
550
+ while !matches?(@scanner.peek, ":", "|", "\n")
551
+ v << read_eval(stop_at_path_sep)
552
+ skip_spaces
553
+ end
554
+ v
555
+ end
556
+
557
+ def matches?(ch, *chars)
558
+ chars.include?(ch)
559
+ end
560
+
561
+ def read_build
562
+ line = @scanner.line
563
+ outs_explicit = read_unevaluated_paths_to(stop_at_path_sep: true)
564
+
565
+ outs_implicit = []
566
+ if @scanner.peek == "|"
567
+ @scanner.next
568
+ outs_implicit = read_unevaluated_paths_to(stop_at_path_sep: true)
569
+ end
570
+
571
+ @scanner.expect(":")
572
+ skip_spaces
573
+ rule = read_ident
574
+
575
+ ins_explicit = read_unevaluated_paths_to(stop_at_path_sep: true)
576
+
577
+ ins_implicit = []
578
+ if @scanner.peek == "|"
579
+ @scanner.next
580
+ peek = @scanner.peek
581
+ if peek == "|" || peek == "@"
582
+ @scanner.back
583
+ else
584
+ ins_implicit = read_unevaluated_paths_to(stop_at_path_sep: true)
585
+ end
586
+ end
587
+
588
+ ins_order_only = []
589
+ if @scanner.peek == "|"
590
+ @scanner.next
591
+ if @scanner.peek == "@"
592
+ @scanner.back
593
+ else
594
+ @scanner.expect("|")
595
+ ins_order_only = read_unevaluated_paths_to(stop_at_path_sep: true)
596
+ end
597
+ end
598
+
599
+ ins_validation = []
600
+ if @scanner.peek == "|"
601
+ @scanner.next
602
+ @scanner.expect("@")
603
+ ins_validation = read_unevaluated_paths_to(stop_at_path_sep: true)
604
+ end
605
+
606
+ @scanner.expect("\n")
607
+ vars = read_scoped_vars(variable_name_validator: lambda { |_| true })
608
+
609
+ {
610
+ rule: rule,
611
+ line: line,
612
+ outs: {
613
+ explicit: outs_explicit,
614
+ implicit: outs_implicit
615
+ },
616
+ ins: {
617
+ explicit: ins_explicit,
618
+ implicit: ins_implicit,
619
+ order_only: ins_order_only,
620
+ validation: ins_validation
621
+ },
622
+ vars: vars
623
+ }
624
+ end
625
+
626
+ def read_default
627
+ defaults = read_unevaluated_paths_to(stop_at_path_sep: true)
628
+ raise Error, "expected path" if defaults.empty?
629
+ @scanner.expect("\n")
630
+ { targets: defaults }
631
+ end
632
+
633
+ def read_eval(stop_at_path_sep)
634
+ result = +""
635
+ start = @scanner.offset
636
+ consumed = false
637
+
638
+ if stop_at_path_sep
639
+ loop do
640
+ ch = @scanner.read
641
+ case ch
642
+ when "\0"
643
+ raise Error, "unexpected EOF"
644
+ when " ", ":", "|", "\n"
645
+ @scanner.back
646
+ break
647
+ when "$"
648
+ # Append literal part before $
649
+ if @scanner.offset > start + 1
650
+ result << @scanner.slice(start, @scanner.offset - 1)
651
+ end
652
+ # Handle escape sequence
653
+ append_escape(result)
654
+ start = @scanner.offset
655
+ consumed = true
656
+ else
657
+ consumed = true
658
+ end
659
+ end
660
+ else
661
+ loop do
662
+ ch = @scanner.read
663
+ case ch
664
+ when "\0"
665
+ raise Error, "unexpected EOF"
666
+ when "\n"
667
+ @scanner.back
668
+ break
669
+ when "$"
670
+ # Append literal part before $
671
+ if @scanner.offset > start + 1
672
+ result << @scanner.slice(start, @scanner.offset - 1)
673
+ end
674
+ # Handle escape sequence
675
+ append_escape(result)
676
+ start = @scanner.offset
677
+ consumed = true
678
+ else
679
+ consumed = true
680
+ end
681
+ end
682
+ end
683
+
684
+ # Append remaining literal part
685
+ if @scanner.offset > start
686
+ result << @scanner.slice(start, @scanner.offset)
687
+ end
688
+
689
+ raise Error, "Expected a string" unless consumed
690
+
691
+ result
692
+ end
693
+
694
+ def read_simple_varname
695
+ start = @scanner.offset
696
+ while (c = @scanner.read)
697
+ break unless c.match?(/[a-zA-Z0-9_-]/)
698
+ break if c == "\0"
699
+ end
700
+ @scanner.back
701
+ ending = @scanner.offset
702
+ raise Error, "failed to scan variable name" if ending == start
703
+ @scanner.slice(start, ending)
704
+ end
705
+
706
+ def append_escape(result)
707
+ case @scanner.read
708
+ when "\n"
709
+ @scanner.skip_spaces
710
+ # Line continuation: $ at end of line, do nothing
711
+ when " ", "$", ":"
712
+ # Literal character
713
+ result << @scanner.slice(@scanner.offset - 1, @scanner.offset)
714
+ when "{"
715
+ # ${var} form
716
+ result << "$"
717
+ result << "{"
718
+ start = @scanner.offset
719
+ loop do
720
+ case @scanner.read
721
+ when "\0"
722
+ raise Error, "unexpected EOF"
723
+ when "}"
724
+ result << @scanner.slice(start, @scanner.offset - 1)
725
+ result << "}"
726
+ break
727
+ end
728
+ end
729
+ else
730
+ # $var form
731
+ @scanner.back
732
+ result << "$"
733
+ var = read_simple_varname
734
+ result << var
735
+ end
736
+ end
737
+
738
+ def skip_spaces
739
+ loop do
740
+ case @scanner.read
741
+ when " "
742
+ when "$"
743
+ if @scanner.peek == "\n"
744
+ @scanner.read # consume newline and continue loop to skip leading spaces on next line
745
+ else
746
+ @scanner.back
747
+ break
748
+ end
749
+ else
750
+ @scanner.back
751
+ break
752
+ end
753
+ end
754
+ end
755
+ end
756
+
757
+ private_constant :Parser
758
+
759
+ # Evaluator visitor that parses and fully evaluates Ninja manifests.
760
+ class Evaluator < Visitor # :nodoc:
761
+ def initialize(**opts)
762
+ # Global vars stack: array of simple hash of evaluated strings
763
+ # The last element of the stack is the current global vars.
764
+ @global_vars_stack = [{}]
765
+ # Rules: simple hash of evaluated strings
766
+ @rules = { "phony" => {} }
767
+ # Returns an array of build records.
768
+ @builds = []
769
+ # Returns an array of default target paths (fully expanded).
770
+ @defaults = []
771
+ # Returns a hash of pool definitions.
772
+ @pools = {}
773
+ @file_opener =
774
+ opts[:file_opener] ||
775
+ ->(path, mode, &block) { File.open(path, mode, &block) }
776
+ end
777
+
778
+ def visit_variable(name:, value:)
779
+ # Evaluate immediately with current global vars
780
+ global_env = lambda { |key| @global_vars_stack.last[key] }
781
+ evaluated = expand(value, [global_env])
782
+ @global_vars_stack.last[name] = evaluated
783
+ end
784
+
785
+ def visit_rule(name:, vars:)
786
+ @rules[name] = vars.transform_values { |val| val.nil? ? nil : val.dup }
787
+ end
788
+
789
+ def visit_default(targets:)
790
+ global_env = lambda { |key| @global_vars_stack.last[key] }
791
+ expand_env = [global_env]
792
+
793
+ expanded_targets = targets.map { |target| expand(target, expand_env) }
794
+ @defaults.concat(expanded_targets)
795
+ end
796
+
797
+ def visit_pool(name:, vars:)
798
+ # Evaluate pool variables with global env
799
+ global_env = lambda { |key| @global_vars_stack.last[key] }
800
+ evaluated_vars = {}
801
+ vars.each do |key, value|
802
+ evaluated_vars[key] = expand(value, [global_env]) if value
803
+ end
804
+ @pools[name] = evaluated_vars
805
+ end
806
+
807
+ def visit_include(path:)
808
+ # Expand path variable references
809
+ global_env = lambda { |key| @global_vars_stack.last[key] }
810
+ expanded_path = expand(path, [global_env])
811
+ @file_opener.call(expanded_path, "r") do |file|
812
+ # Parse the included file with the current evaluator as the visitor
813
+ NinjaManifest.parse(file.read, self)
814
+ end
815
+ end
816
+
817
+ def visit_subninja(path:)
818
+ global_env = lambda { |key| @global_vars_stack.last[key] }
819
+ expanded_path = expand(path, [global_env])
820
+
821
+ # Push a new global vars hash onto the stack
822
+ @global_vars_stack.push({})
823
+ begin
824
+ @file_opener.call(expanded_path, "r") do |file|
825
+ # Parse the subninja file with the current evaluator as the visitor
826
+ NinjaManifest.parse(file.read, self)
827
+ end
828
+ ensure
829
+ # Pop the global vars hash from the stack
830
+ @global_vars_stack.pop
831
+ end
832
+ end
833
+
834
+ def visit_build(rule:, outs:, ins:, vars:, **kwargs)
835
+ rule_attrs = @rules[rule]
836
+ raise Error, "unknown rule #{rule.inspect}" unless rule_attrs
837
+
838
+ # https://ninja-build.org/manual.html#ref_scope
839
+ # Variable lookup order:
840
+ # 1. Special built-in variables ($in, $out)
841
+ # 2. Build-level variables from the build block
842
+ # 3. Rule-level variables from the rule block
843
+ # 4. File-level variables from the file that the build line was in
844
+ # 5. Variables from files that included this file using subninja keyword
845
+
846
+ # Level 4 & 5: File-level variables (current file and parent files via subninja)
847
+ lookup_file_level_env =
848
+ lambda do |key|
849
+ # Search from current file (Level 4) to parent files (Level 5)
850
+ @global_vars_stack.reverse_each do |vars|
851
+ return vars[key] if vars.key?(key)
852
+ end
853
+ nil
854
+ end
855
+
856
+ # Level 3: Rule-level variables
857
+ lookup_rule_vars = lambda { |key| rule_attrs[key] }
858
+
859
+ # Level 2: Build-level variables (raw, unevaluated)
860
+ build_vars_raw = vars
861
+ lookup_build_vars_env = lambda { |key| build_vars_raw[key] }
862
+
863
+ # Evaluate paths using levels 2, 4, and 5
864
+ path_envs = [lookup_build_vars_env, lookup_file_level_env]
865
+ outputs = {
866
+ explicit: outs[:explicit].map { |val| expand(val, path_envs) },
867
+ implicit: outs[:implicit].map { |val| expand(val, path_envs) }
868
+ }
869
+
870
+ inputs = {
871
+ explicit: ins[:explicit].map { |val| expand(val, path_envs) },
872
+ implicit: ins[:implicit].map { |val| expand(val, path_envs) },
873
+ order_only: ins[:order_only].map { |val| expand(val, path_envs) },
874
+ validation: ins[:validation].map { |val| expand(val, path_envs) }
875
+ }
876
+
877
+ # Level 1: Special built-in variables (requires evaluated paths)
878
+ lookup_implicit_vars =
879
+ lambda do |key|
880
+ case key
881
+ when "in"
882
+ inputs[:explicit].join(" ")
883
+ when "in_newline"
884
+ inputs[:explicit].join("\n")
885
+ when "out"
886
+ outputs[:explicit].join(" ")
887
+ when "out_newline"
888
+ outputs[:explicit].join("\n")
889
+ else
890
+ nil
891
+ end
892
+ end
893
+
894
+ path_envs = [
895
+ lookup_implicit_vars,
896
+ lookup_build_vars_env,
897
+ lookup_rule_vars,
898
+ lookup_file_level_env
899
+ ]
900
+ final_vars = {}
901
+ rule_attrs.each do |key, value|
902
+ final_vars[key] = expand(value, path_envs)
903
+ end
904
+ build_vars_raw.each do |key, value|
905
+ final_vars[key] = expand(value, path_envs)
906
+ end
907
+
908
+ record = {
909
+ rule: rule,
910
+ outputs: outputs,
911
+ inputs: inputs,
912
+ vars: final_vars
913
+ }
914
+
915
+ @builds << record
916
+ record
917
+ end
918
+
919
+ def finalize
920
+ Manifest.new(
921
+ variables: @global_vars_stack.last.dup,
922
+ rules: @rules.dup,
923
+ builds: @builds.dup,
924
+ defaults: @defaults.dup,
925
+ pools: @pools.dup
926
+ )
927
+ end
928
+
929
+ private
930
+
931
+ def expand(text, env_procs)
932
+ result = +""
933
+ i = 0
934
+ while i < text.length
935
+ char = text[i]
936
+ if char != "$"
937
+ result << char
938
+ i += 1
939
+ next
940
+ end
941
+
942
+ i += 1
943
+ break if i >= text.length
944
+
945
+ next_char = text[i]
946
+
947
+ case next_char
948
+ when "$", " ", ":"
949
+ result << next_char
950
+ i += 1
951
+ when "{"
952
+ i += 1
953
+ start = i
954
+ i += 1 while i < text.length && text[i] != "}"
955
+ name = text[start...i]
956
+ i += 1 if i < text.length
957
+ result << (expand(lookup_variable(name, env_procs) || "", env_procs))
958
+ else
959
+ start = i
960
+ i += 1 while i < text.length && text[i].match?(/[A-Za-z0-9_-]/)
961
+ name = text[start...i]
962
+ if name.empty?
963
+ result << "$"
964
+ else
965
+ result << (
966
+ expand(lookup_variable(name, env_procs) || "", env_procs)
967
+ )
968
+ end
969
+ end
970
+ end
971
+ result
972
+ end
973
+
974
+ def lookup_variable(name, env_procs)
975
+ env_procs.each do |env|
976
+ next unless env
977
+
978
+ value = env.call(name)
979
+ return value unless value.nil?
980
+ end
981
+ nil
982
+ end
983
+ end
984
+
985
+ private_constant :Evaluator
986
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/ninja_manifest"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "ninja_manifest"
7
+ spec.version = NinjaManifest::VERSION
8
+ spec.authors = ["Yuta Saito"]
9
+ spec.email = ["katei@ruby-lang.org"]
10
+
11
+ spec.summary = "A Ninja build manifest toolkit"
12
+ spec.description = <<END
13
+ NinjaManifest is a Ninja build manifest toolkit, including a parser and evaluator.
14
+ END
15
+ spec.homepage = "https://github.com/kateinoigakukun/ninja_manifest"
16
+ spec.license = "MIT"
17
+ spec.required_ruby_version = ">= 3.1.0"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata[
21
+ "source_code_uri"
22
+ ] = "https://github.com/kateinoigakukun/ninja_manifest"
23
+
24
+ # Specify which files should be added to the gem when it is released.
25
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
26
+ spec.files =
27
+ Dir.chdir(__dir__) do
28
+ `git ls-files -z`.split("\x0")
29
+ .reject do |f|
30
+ (f == __FILE__) ||
31
+ f.match(
32
+ %r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}
33
+ )
34
+ end
35
+ end
36
+ spec.require_paths = ["lib"]
37
+ end
metadata ADDED
@@ -0,0 +1,54 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ninja_manifest
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yuta Saito
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-11-03 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: 'NinjaManifest is a Ninja build manifest toolkit, including a parser
14
+ and evaluator.
15
+
16
+ '
17
+ email:
18
+ - katei@ruby-lang.org
19
+ executables: []
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - Gemfile
24
+ - LICENSE
25
+ - README.md
26
+ - Rakefile
27
+ - lib/ninja_manifest.rb
28
+ - ninja_manifest.gemspec
29
+ homepage: https://github.com/kateinoigakukun/ninja_manifest
30
+ licenses:
31
+ - MIT
32
+ metadata:
33
+ homepage_uri: https://github.com/kateinoigakukun/ninja_manifest
34
+ source_code_uri: https://github.com/kateinoigakukun/ninja_manifest
35
+ post_install_message:
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: 3.1.0
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 3.5.3
51
+ signing_key:
52
+ specification_version: 4
53
+ summary: A Ninja build manifest toolkit
54
+ test_files: []