• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env ruby
2
3# Validation program for the new YAML headers at the top of TeX hyphenation files.
4# Run on an individual file or a directory to get a report of all the errors on the terminal.
5# Copyright (c) 2016–2017 Arthur Reutenauer.  MIT licence (https://opensource.org/licenses/MIT)
6
7# TODO Add the optional “source” top-level entry
8
9require 'yaml'
10require 'pp'
11
12class HeaderValidator
13  class WellFormednessError < StandardError # probably not an English word ;-)
14  end
15
16  class ValidationError < StandardError
17  end
18
19  class InternalError < StandardError
20  end
21
22  @@format = {
23    title: {
24      mandatory: true,
25      type: String,
26    },
27    copyright: {
28      mandatory: true,
29      type: String,
30    },
31    authors: {
32       mandatory: true,
33       type: Array,
34    },
35    language: {
36      mandatory: true,
37      type: {
38        name: {
39          mandatory: true,
40          type: String,
41        },
42        tag: {
43          mandatory: true,
44          type: String,
45        },
46      },
47    },
48    version: {
49      mandatory: false,
50      type: String,
51    },
52    notice: {
53      mandatory: true,
54      type: String,
55    },
56    licence: {
57      mandatory: true,
58      one_or_more: true,
59      type: "[Knuth only knows]", # TODO Define
60    },
61    changes: {
62      mandatory: false,
63      type: String,
64    },
65    hyphenmins: {
66      mandatory: true,
67      type: {
68        generation: {
69          mandatory: false, # TODO Find way to describe that it *is* mandatory if typesetting absent
70          type: {
71            left: {
72              mandatory: true,
73              type: Integer,
74            },
75            right: {
76              mandatory: true,
77              type: Integer,
78            },
79          },
80        },
81        typesetting: {
82          mandatory: false,
83          type: {
84            left: {
85              mandatory: true,
86              type: Integer,
87            },
88            right: {
89              mandatory: true,
90              type: Integer,
91            },
92          },
93        },
94      },
95    },
96    texlive: {
97      mandatory: false,
98      type: {
99        synonyms: {
100          mandatory: false,
101          type: Array
102        },
103        encoding: {
104          mandatory: false,
105          type: String
106        },
107        message: {
108          mandatory: false,
109          type: String
110        },
111        legacy_patterns: {
112          mandatory: false,
113          type: String
114        },
115        use_old_loader: {
116          mandatory: false,
117          # type: Bool # FIXME
118        },
119        use_old_patterns_comment: {
120          mandatory: false,
121          type: String
122        },
123        description: {
124          mandatory: false,
125          type: String
126        },
127        babelname: {
128          mandatory: false,
129          type: String
130        },
131        package: {
132          mandatory: false,
133          type: String
134        }
135      },
136    },
137    source: {
138      mandatory: false,
139      type: String
140    },
141    known_bugs: {
142      mandatory: false,
143      type: Array # of strings!
144    }
145  }
146
147  def initialize
148    @errors = { }
149  end
150
151  def parse(filename)
152    header = ''
153    eohmarker = '=' * 42
154    File.read(filename).each_line do |line|
155      if line =~ /\\patterns|#{eohmarker}/
156        break
157      end
158
159      line.gsub!(/^% /, '')
160      line.gsub!(/%/, '')
161      header += line
162    end
163
164    begin
165      @metadata = YAML::load(header)
166      bcp47 = filename.gsub(/.*hyph-/, '').gsub(/\.tex$/, '')
167      raise ValidationError.new("Empty metadata set for language [#{bcp47}]") unless @metadata
168    rescue Psych::SyntaxError => err
169      raise WellFormednessError.new(err.message)
170    end
171  end
172
173  def check_mandatory(hash, validator)
174    validator.each do |key, validator|
175      if validator[:mandatory]
176        if !hash.include? key.to_s # Subtle difference between key not present and value is nil :-)
177          raise ValidationError.new("Key #{key} missing") # TODO Say where!
178        end
179      end
180      check_mandatory(hash[key.to_s], validator[:type]) if hash[key.to_s] && validator[:type].respond_to?(:keys)
181    end
182  end
183
184  def validate(hash, validator)
185    hash.each do |key, value|
186      raise ValidationError.new("Invalid key #{key} found") if validator[key.to_sym] == nil
187      raise ValidationError.new("P & S") if key == 'texlive' && hash['texlive']['package'] && hash['texlive']['description']
188      validate(value, validator[key.to_sym][:type]) if value.respond_to?(:keys) && !validator[key.to_sym][:one_or_more]
189    end
190  end
191
192  def run!(pattfile)
193    unless File.file?(pattfile)
194      raise InternalError.new("Argument “#{pattfile}” is not a file; this shouldn’t have happened.")
195    end
196    parse(pattfile)
197    check_mandatory(@metadata, @@format)
198    validate(@metadata, @@format)
199    { title: @metadata['title'], copyright: @metadata['copyright'], notice: @metadata['notice'] }
200  end
201
202  def runfile(filename)
203    begin
204      run! filename
205    rescue InternalError, WellFormednessError, ValidationError => err
206      (@errors[filename] ||= []) << [err.class, err.message]
207    end
208  end
209
210  def main(args)
211    print 'Validating '
212    # TODO Sort input file list in alphabetical order of names!
213    @mode = 'default'
214    arg = args.shift
215    if arg == '-m' # Mojca mode
216      @mode = 'mojca'
217    else
218      args = [arg] + args
219    end
220    @headings = []
221    while !args.empty?
222      arg = args.shift
223      unless arg
224        puts "Please give me at least one file to validate."
225        exit -1
226      end
227
228      if File.file? arg
229        print 'file ', arg
230        @headings << [File.basename(arg), runfile(arg)]
231      elsif Dir.exists? arg
232        print 'files in ', arg, ': '
233        print(Dir.foreach(arg).map do |filename|
234          next if filename == '.' || filename == '..'
235          f = File.join(arg, filename)
236          @headings << [filename, runfile(f)] unless Dir.exists? f
237          filename.gsub(/^hyph-/, '').gsub(/\.tex$/, '')
238        end.compact.sort.join ' ')
239      else
240        puts "Argument #{arg} is neither an existing file nor an existing directory; proceeding." unless @mode == 'mojca'
241      end
242
243      puts
244    end
245
246    if @mode == 'mojca'
247      @headings.sort! { |a, b| a.first <=> b.first }
248      [:title, :copyright, :notice].each do |heading|
249        puts heading.capitalize
250        puts @headings.map { |filedata| "#{filedata.first}: #{filedata.last[heading]}" }.join("\n")
251        puts ""
252      end
253    else
254      list = if Dir.exists?(arg) then arg else @headings.map(&:first).join ' ' end
255      puts "\nReport on #{list}:"
256      summary = []
257      @errors.each do |filename, messages|
258        messages.each do |file|
259          classname = file.first
260          message = file.last
261          summary << "#{filename}: #{classname} #{message}"
262        end
263      end
264
265      if (exitcode = summary.count) > 0
266        puts "There were the following errors with some files:"
267        puts summary.join "\n"
268        exit exitcode
269      else
270        puts "No errors were found."
271      end
272    end
273  end
274end
275
276validator = HeaderValidator.new
277validator.main(ARGV)
278