Added detector and parser for Pike (http://pike.ida.liu.se/).
[ohcount] / lib / ohcount / detector.rb
1 # The Detector determines which Monoglot or Polyglot should be
2 # used to parse a source file.
3 #
4 # The Detector primarily uses filename extensions to identify languages.
5 #
6 # The hash EXTENSION_MAP maps a filename extension to the name of a parser.
7 #
8 # If a filename extension is not enough to determine the correct parser (for
9 # instance, the *.m extension can indicate either a Matlab or Objective-C file),
10 # then the EXTENSION_MAP hash will contain a symbol identifying a Ruby method
11 # which will be invoked. This Ruby method can examine the file
12 # contents and return the name of the correct parser.
13 #
14 # Many source files do not have an extension. The method +disambiguate_nil+
15 # is called in these cases. The +file+ command line tool is used to determine
16 # the type of file and select a parser.
17 #
18 # The Detector is covered by DetectorTest.
19 #
20 class Ohcount::Detector
21
22         module ContainsM
23                 # A performance hack -- once we've checked for the presence of *.m files, the result
24                 # is stored here to avoid checking twice.
25                 attr_accessor :contains_m
26                 # A performance hack -- once we've checked for the presence of *.pike and *.pmod files, the result
27                 # is stored here to avoid checking twice.
28                 attr_accessor :contains_pike_or_pmod
29         end
30
31         # The primary entry point for the detector.
32         # Given a file context containing the file name, content, and an array of
33         # other filenames in the source tree, attempts to detect which
34         # language family (Monoglot or Polyglot) is in use for this file.
35         #
36         # Returns nil if the language is not recognized or if the file does not
37         # contain any code.
38         #
39         # Example:
40         #
41         #   # List all C/C++ files in the 'src' directory
42         #   Dir.entries("src").each do |file|
43         #     context = Ohcount::SimpleFileContext.new(file)
44         #     polyglot = Ohcount::Detector.detect(context)
45         #     puts "#{file}" if polyglot == 'cncpp'
46         #   end
47         #
48         def self.detect(file_context)
49                 # start with extension
50                 polyglot = EXTENSION_MAP[File.extname(file_context.filename).downcase]
51     case polyglot
52     when String
53       # simplest case
54                   return polyglot if polyglot.is_a?(String)
55     when Symbol
56                   # extension is ambiguous - requires custom disambiguation
57                         self.send(polyglot, file_context)
58     when NilClass
59       return disambiguate_nil(file_context)
60     else
61       raise RuntimeError.new("Unknown file detection type")
62           end
63   end
64
65         # Based solely on the filename, makes a judgment whether a file is a binary format.
66         def self.binary_filename?(filename)
67                 ignore = [
68                         ".svn",
69                         ".jar",
70                         ".tar",
71                         ".gz",
72                         ".tgz",
73                         ".zip",
74                         ".gif",
75                         ".jpg",
76                         ".jpeg",
77                         ".bmp",
78                         ".png",
79                         ".tif",
80                         ".tiff",
81                         ".ogg",
82                         ".aiff",
83                         ".wav",
84                         ".mp3",
85                         ".au",
86                         ".ra",
87                         ".m4a",
88                         ".pdf",
89                         ".mpg",
90                         ".mov",
91                         ".qt",
92                         ".avi"
93                         ]
94                 ignore.include?(File.extname(filename))
95         end
96
97         # If an extension maps to a string, that string must be the name of a glot.
98         # If an extension maps to a Ruby symbol, that symbol must be the name of a
99         # Ruby method which will return the name of a glot.
100         EXTENSION_MAP = {
101                 '.ada'  => "ada",
102                 '.adb'  => "ada",
103                 '.ads'  => "ada",
104                 '.asm'  => "assembler",
105                 '.awk'  => "awk",
106                 '.bas'  => "visualbasic",
107                 '.bat'  => "bat",
108                 '.boo'  => "boo",
109                 '.c'    => "cncpp",
110                 '.cc'   => "cncpp",
111                 '.cpp'  => "cncpp",
112                 '.css'  => "css",
113                 '.c++'  => "cncpp",
114                 '.cxx'  => "cncpp",
115                 '.el'   => "emacslisp",
116                 #               '.cbl'  => "cobol",
117                 #               '.cob'  => "cobol",
118                 '.cs'   => :disambiguate_cs,
119                 '.dylan'=> "dylan",
120                 '.erl'  => "erlang",
121                 '.f'    => :disambiguate_fortran,
122                 '.ftn'  => :disambiguate_fortran,
123                 '.f77'  => :disambiguate_fortran,
124                 '.f90'  => :disambiguate_fortran,
125                 '.f95'  => :disambiguate_fortran,
126                 '.f03'  => :disambiguate_fortran,
127                 '.F'    => :disambiguate_fortran,
128                 '.F77'  => :disambiguate_fortran,
129                 '.F90'  => :disambiguate_fortran,
130                 '.F95'  => :disambiguate_fortran,
131                 '.F03'  => :disambiguate_fortran,
132                 '.frx'  => "visualbasic",
133                 '.groovy'=> "groovy",
134                 '.h'    => :disambiguate_h_header,
135                 '.hpp'  => "cncpp",
136                 '.h++'  => "cncpp",
137                 '.hs'   => "haskell",
138                 '.hxx'  => "cncpp",
139                 '.hh'   => "cncpp",
140                 '.hrl'  => "erlang",
141                 '.htm'  => "html",
142                 '.html' => "html",
143                 '.inc'  => :disambiguate_inc,
144                 '.java' => "java",
145                 '.js'   => "javascript",
146                 '.jsp'  => "jsp",
147                 '.lua'  => "lua",
148                 '.lsp'  => "lisp",
149                 '.lisp' => "lisp",
150                 '.m'    => :matlab_or_objective_c,
151                 '.mm'   => "objective_c",
152                 '.pas'  => "pascal",
153                 '.pp'   => "pascal",
154                 '.php'  => "php",
155                 '.php3' => "php",
156                 '.php4' => "php",
157                 '.php5' => "php",
158                 '.pl'   => "perl",
159                 '.pm'   => "perl",
160                 '.perl' => "perl",
161                 '.ph'   => "perl",
162                 '.pike' => "pike",
163                 '.pmod' => "pike",
164                 '.py'   => "python",
165                 '.rhtml'=> "rhtml",
166                 '.rb'   => "ruby",
167                 '.rex'  => "rexx",
168                 '.rexx' => "rexx",
169                 '.s'    => "assembler",
170                 '.S'    => "assembler",
171                 '.sc'   => "scheme",
172                 '.scm'  => "scheme",
173                 '.sh'   => "shell",
174                 '.sql'  => "sql",
175                 '.st'   => "smalltalk",
176                 '.tcl'  => "tcl",
177                 '.tpl'  => "html",
178                 '.vala' => "vala",
179                 '.vb'   => "visualbasic",
180                 '.vba'  => "visualbasic",
181                 '.vbs'  => "visualbasic",
182                 '.xml'  => "xml",
183                 '.xsd'  => "xmlschema",
184                 '.xsl'  => "xslt",
185                 '.d'            => 'dmd',
186                 '.di'           => 'dmd',
187                 '.tex'  => 'tex',
188                 '.latex'=> 'tex'
189         }
190
191         protected
192
193         # Returns a count of lines in the buffer matching the given regular expression.
194         def self.lines_matching(buffer, re)
195                 buffer.inject(0) { |total, line| line =~ re ? total+1 : total }
196         end
197
198         # For *.m files, differentiates Matlab from Objective-C.
199         #
200         # This is done with a weighted heuristic that
201         # scans the *.m file contents for keywords,
202         # and also checks for the presence of matching *.h files.
203   def self.matlab_or_objective_c(file_context)
204     buffer = file_context.contents
205
206     # if there are .h files in same directory, this probably isn't matlab
207     h_headers = 0.0
208     h_headers = -0.5 if file_context.filenames.select { |a| a =~ /\.h$/ }.any?
209
210     # if the contents contain 'function (' on a single line - very likely to be matlab
211     # if the contents contain lines starting with '%', its probably matlab comments
212     matlab_signatures = /(^\s*function\s*)|(^\s*%)/
213     matlab_sig_score = 0.1 * lines_matching(buffer, matlab_signatures)
214
215     # if the contents contains '//' or '/*', likely objective_c
216     objective_c_signatures = /(^\s*\/\/\s*)|(^\s*\/\*)|(^[+-])/
217     obj_c_sig_score = -0.1 * lines_matching(buffer, objective_c_signatures)
218
219     matlab = h_headers + matlab_sig_score + obj_c_sig_score
220
221     matlab > 0 ? 'matlab' : 'objective_c'
222   end
223
224         # For *.h files, differentiates C/C++ from Objective-C.
225         #
226         # This is done with a weighted heuristic that
227         # scans the *.h file contents for Objective-C keywords,
228         # and also checks for the presence of matching *.m files.
229         def self.disambiguate_h_header(file_context)
230     buffer = file_context.contents
231
232     objective_c = 0
233
234     # could it be realistically be objective_c ? are there any .m files at all?
235     # Speed hack - remember our findings in case we get the same filenames over and over
236     unless defined?(file_context.filenames.contains_m)
237       file_context.filenames.extend(ContainsM)
238       file_context.filenames.contains_m = file_context.filenames.select { |a| a =~ /\.m$/ }.any?
239       file_context.filenames.contains_pike_or_pmod = file_context.filenames.select { |a| a =~ /\.p(ike|mod)$/ }.any?
240     end
241     unless file_context.filenames.contains_pike_or_pmod
242       return 'cncpp' unless file_context.filenames.contains_m
243     end
244
245     if file_context.filenames.contains_m
246       # if the dir contains a matching *.m file, likely objective_c
247       if file_context.filename =~ /\.h$/
248         m_counterpart = file_context.filename.gsub(/\.h$/, ".m")
249         return 'objective_c' if file_context.filenames.include?(m_counterpart)
250       end
251
252       # ok - it just might be objective_c, let's check contents for objective_c signatures
253       objective_c_signatures = /(^\s*@interface)|(^\s*@end)/
254       objective_c += lines_matching(buffer, objective_c_signatures)
255
256       return 'objective_c' if objective_c > 1
257     end
258
259     if file_context.filenames.contains_pike_or_pmod
260       # The string "pike" and a selection of common Pike keywords.
261       pike_signatures = /([Pp][Ii][Kk][Ee])|(string )|(mapping)|(multiset)|(import )|(inherit )|(predef)/
262       pike = lines_matching(buffer, pike_signatures)
263       return 'pike' if pike > 0
264     end
265
266
267     return 'cncpp'
268         end
269
270         # Tests whether the provided buffer contains binary or text content.
271         # This is not fool-proof -- we basically just check for zero values
272         # in the early bytes of the buffer. If we find a zero, we know it
273         # is not (ascii) text.
274   def self.binary_buffer?(buffer)
275     100.times do |i|
276       return true if buffer[i] == 0
277     end
278     false
279   end
280
281         # True if the provided buffer includes a '?php' directive
282   def self.php_instruction?(buffer)
283     buffer =~ /\?php/
284   end
285
286         # For *.inc files, checks for a PHP class.
287   def self.disambiguate_inc(file_context)
288     buffer = file_context.contents
289     return nil if binary_buffer?(buffer)
290     return 'php' if php_instruction?(buffer)
291     nil
292   end
293
294         # For files with extention *.cs, differentiates C# from Clearsilver.
295   def self.disambiguate_cs(file_context)
296     buffer = file_context.contents
297     return 'clearsilver_template' if lines_matching(file_context.contents, /\<\?cs/) > 0
298     return 'csharp'
299   end
300
301   def self.disambiguate_fortran(file_context)
302     buffer = file_context.contents
303
304     definitely_not_f77 = /^ [^0-9 ]{5}/
305     return 'fortranfixed' if lines_matching(buffer, definitely_not_f77) > 0
306
307     free_form_continuation = /&\s*\n\s*&/m
308     return 'fortranfree' if buffer.match(free_form_continuation)
309
310     possibly_fixed = /^ [0-9 ]{5}/
311     contig_number = /^\s*\d+\s*$/
312     buffer.scan(possibly_fixed) {|leader|
313       return 'fortranfixed' if !(leader =~ contig_number) }
314     # Might as well be free-form.
315     return 'fortranfree'
316   end
317
318         # Attempts to determine the Polyglot for files that do not have a
319         # filename extension.
320         #
321         # Relies on the bash +file+ command line tool as its primary method.
322         #
323         # There must be a file at <tt>file_context.file_location</tt> for +file+
324         # to operate on.
325         #
326   def self.disambiguate_nil(file_context)
327     file_location = file_context.file_location
328     output = `file -b #{ file_location }`
329     case output
330     when /([\w\/]+) script text/, /script text executable for ([\w\/]+)/
331       script = $1
332       if script =~ /\/(\w*)$/
333         script = $1
334       end
335       known_languages = EXTENSION_MAP.values
336       return script.downcase if known_languages.include?(script.downcase)
337     when /([\w\-]*) shell script text/
338       case $1
339       when "Bourne-Again"
340         return "shell"
341       end
342     end
343
344     # dang... no dice
345     nil
346   end
347
348 end