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