catalog: count errors and warnings for each package
[nit.git] / src / catalog.nit
1 # This file is part of NIT ( http://www.nitlanguage.org ).
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 # Basic catalog generator for Nit packages
16 #
17 # See: <http://nitlanguage.org/catalog/>
18 #
19 # The tool scans packages and generates the HTML files of a catalog.
20 #
21 # ## Features
22 #
23 # * [X] scan packages and their `.ini`
24 # * [X] generate lists of packages
25 # * [X] generate a page per package with the readme and most metadata
26 # * [ ] link/include/be included in the documentation
27 # * [ ] propose `related packages`
28 # * [X] show directory content (a la nitls)
29 # * [X] gather git information from the working directory
30 # * [ ] gather git information from the repository
31 # * [ ] gather package information from github
32 # * [ ] gather people information from github
33 # * [X] reify people
34 # * [X] separate information gathering from rendering
35 # * [ ] move up information gathering in (existing or new) service modules
36 # * [X] add command line options
37 # * [ ] harden HTML (escaping, path injection, etc)
38 # * [ ] nitcorn server with RESTful API
39 #
40 # ## Issues and limitations
41 #
42 # The tool works likee the other tools and expects to find valid Nit source code in the directories
43 #
44 # * cruft and temporary files will be collected
45 # * missing source file (e.g. not yet generated by nitcc) will make information
46 # incomplete (e.g. invalid module thus partial dependency and metrics)
47 #
48 # How to use the tool as the basis of a Nit code archive on the web usable with a package manager is not clear.
49 module catalog
50
51 import md5 # To get gravatar images
52 import counter # For statistics
53 import modelize # To process and count classes and methods
54
55 redef class MPackage
56 # Return the associated metadata from the `ini`, if any
57 fun metadata(key: String): nullable String
58 do
59 var ini = self.ini
60 if ini == null then return null
61 return ini[key]
62 end
63
64 # The consolidated list of tags
65 var tags = new Array[String]
66
67 # The list of maintainers
68 var maintainers = new Array[Person]
69
70 # The list of contributors
71 var contributors = new Array[Person]
72
73 # The date of the most recent commit
74 var last_date: nullable String = null
75
76 # The date of the oldest commit
77 var first_date: nullable String = null
78 end
79
80 redef class Int
81 # Returns `log(self+1)`. Used to compute score of packages
82 fun score: Float do return (self+1).to_f.log
83 end
84
85 # A contributor/author/etc.
86 #
87 # It comes from git or the metadata
88 #
89 # TODO get more things from github by using the email as a key
90 # "https://api.github.com/search/users?q={email}+in:email"
91 class Person
92 # The name. Eg "John Doe"
93 var name: String is writable
94
95 # The email, Eg "john.doe@example.com"
96 var email: nullable String is writable
97
98 # Some homepage. Eg "http://example.com/~jdoe"
99 var page: nullable String is writable
100
101 # Return a full-featured link to a person
102 fun to_html: String
103 do
104 var res = ""
105 var e = name.html_escape
106 var page = self.page
107 if page != null then
108 res += "<a href=\"{page.html_escape}\">"
109 end
110 var email = self.email
111 if email != null then
112 var md5 = email.md5.to_lower
113 res += "<img src=\"https://secure.gravatar.com/avatar/{md5}?size=20&amp;default=retro\">&nbsp;"
114 end
115 res += e
116 if page != null then res += "</a>"
117 return res
118 end
119
120 # The standard representation of a person.
121 #
122 # ~~~
123 # var jd = new Person("John Doe", "john.doe@example.com", "http://example.com/~jdoe")
124 # assert jd.to_s == "John Doe <john.doe@example.com> (http://example.com/~jdoe)"
125 # ~~~
126 #
127 # It can be used as the input of `parse`.
128 #
129 # ~~~
130 # var jd2 = new Person.parse(jd.to_s)
131 # assert jd2.to_s == jd.to_s
132 # ~~~
133 redef fun to_s
134 do
135 var res = name
136 var email = self.email
137 if email != null then res += " <{email}>"
138 var page = self.page
139 if page != null then res += " ({page})"
140 return res
141 end
142
143 # Crete a new person from its standard textual representation.
144 #
145 # ~~~
146 # var jd = new Person.parse("John Doe <john.doe@example.com> (http://example.com/~jdoe)")
147 # assert jd.name == "John Doe"
148 # assert jd.email == "john.doe@example.com"
149 # assert jd.page == "http://example.com/~jdoe"
150 # ~~~
151 #
152 # Emails and page are optional.
153 #
154 # ~~~
155 # var jd2 = new Person.parse("John Doe")
156 # assert jd2.name == "John Doe"
157 # assert jd2.email == null
158 # assert jd2.page == null
159 # ~~~
160 init parse(person: String)
161 do
162 var name = person
163 var email = null
164 var page = null
165 # Regular expressions are broken, need to investigate.
166 # So split manually.
167 #
168 #var re = "([^<(]*?)(<([^>]*?)>)?(\\((.*)\\))?".to_re
169 #var m = (person+" ").search(re)
170 #print "{person}: `{m or else "?"}` `{m[1] or else "?"}` `{m[3] or else "?"}` `{m[5] or else "?"}`"
171 do
172 var sp1 = person.split_once_on("<")
173 if sp1.length < 2 then
174 break
175 end
176 var sp2 = sp1.last.split_once_on(">")
177 if sp2.length < 2 then
178 break
179 end
180 name = sp1.first.trim
181 email = sp2.first.trim
182 var sp3 = sp2.last.split_once_on("(")
183 if sp3.length < 2 then
184 break
185 end
186 var sp4 = sp3.last.split_once_on(")")
187 if sp4.length < 2 then
188 break
189 end
190 page = sp4.first.trim
191 end
192
193 init(name, email, page)
194 end
195 end
196
197
198 # The main class of the calatog generator that has the knowledge
199 class Catalog
200
201 # The modelbuilder
202 # used to access the files and count source lines of code
203 var modelbuilder: ModelBuilder
204
205 # Packages by tag
206 var tag2proj = new MultiHashMap[String, MPackage]
207
208 # Packages by category
209 var cat2proj = new MultiHashMap[String, MPackage]
210
211 # Packages by maintainer
212 var maint2proj = new MultiHashMap[Person, MPackage]
213
214 # Packages by contributors
215 var contrib2proj = new MultiHashMap[Person, MPackage]
216
217 # Dependency between packages
218 var deps = new POSet[MPackage]
219
220 # Number of modules by package
221 var mmodules = new Counter[MPackage]
222
223 # Number of classes by package
224 var mclasses = new Counter[MPackage]
225
226 # Number of methods by package
227 var mmethods = new Counter[MPackage]
228
229 # Number of line of code by package
230 var loc = new Counter[MPackage]
231
232 # Number of errors
233 var errors = new Counter[MPackage]
234
235 # Number of warnings and advices
236 var warnings = new Counter[MPackage]
237
238 # Number of commits by package
239 var commits = new Counter[MPackage]
240
241 # Score by package
242 #
243 # The score is loosely computed using other metrics
244 var score = new Counter[MPackage]
245
246 # List of known people
247 var persons = new HashMap[String, Person]
248
249 # Scan, register and add a contributor to a package
250 fun register_contrib(person: String, mpackage: MPackage): Person
251 do
252 var p = persons.get_or_null(person)
253 if p == null then
254 p = new Person.parse(person)
255 persons[person] = p
256 end
257 var projs = contrib2proj[p]
258 if not projs.has(mpackage) then
259 projs.add mpackage
260 mpackage.contributors.add p
261 end
262 return p
263 end
264
265 # Compute information for a package
266 fun package_page(mpackage: MPackage)
267 do
268 var score = score[mpackage].to_f
269
270 var mdoc = mpackage.mdoc_or_fallback
271 if mdoc != null then
272 score += 100.0
273 score += mdoc.content.length.score
274 end
275
276
277 var tryit = mpackage.metadata("upstream.tryit")
278 if tryit != null then
279 score += 1.0
280 end
281 var apk = mpackage.metadata("upstream.apk")
282 if apk != null then
283 score += 1.0
284 end
285
286 var homepage = mpackage.metadata("upstream.homepage")
287 if homepage != null then
288 score += 5.0
289 end
290 var maintainer = mpackage.metadata("package.maintainer")
291 if maintainer != null then
292 score += 5.0
293 var person = register_contrib(maintainer, mpackage)
294 mpackage.maintainers.add person
295 var projs = maint2proj[person]
296 if not projs.has(mpackage) then projs.add mpackage
297 end
298 var license = mpackage.metadata("package.license")
299 if license != null then
300 score += 5.0
301 end
302
303 var browse = mpackage.metadata("upstream.browse")
304 if browse != null then
305 score += 5.0
306 end
307
308 var tags = mpackage.metadata("package.tags")
309 var ts = mpackage.tags
310 if tags != null then
311 for t in tags.split(",") do
312 t = t.trim
313 if t == "" then continue
314 ts.add t
315 end
316 end
317 if ts.is_empty then ts.add "none"
318 if tryit != null then ts.add "tryit"
319 if apk != null then ts.add "apk"
320 for t in ts do
321 tag2proj[t].add mpackage
322 end
323 var cat = ts.first
324 cat2proj[cat].add mpackage
325 score += ts.length.score
326
327 if deps.has(mpackage) then
328 score += deps[mpackage].greaters.length.score
329 score += deps[mpackage].direct_greaters.length.score
330 score += deps[mpackage].smallers.length.score
331 score += deps[mpackage].direct_smallers.length.score
332 end
333
334 var contributors = mpackage.contributors
335 var more_contributors = mpackage.metadata("package.more_contributors")
336 if more_contributors != null then
337 for c in more_contributors.split(",") do
338 register_contrib(c.trim, mpackage)
339 end
340 end
341 score += contributors.length.to_f
342
343 var mmodules = 0
344 var mclasses = 0
345 var mmethods = 0
346 var loc = 0
347 var errors = 0
348 var warnings = 0
349 for g in mpackage.mgroups do
350 mmodules += g.mmodules.length
351 for m in g.mmodules do
352 var source = m.location.file
353 if source != null then
354 for msg in source.messages do
355 if msg.level == 2 then
356 errors += 1
357 else
358 warnings += 1
359 end
360 end
361 end
362 var am = modelbuilder.mmodule2node(m)
363 if am != null then
364 var file = am.location.file
365 if file != null then
366 loc += file.line_starts.length - 1
367 end
368 end
369 for cd in m.mclassdefs do
370 mclasses += 1
371 for pd in cd.mpropdefs do
372 if not pd isa MMethodDef then continue
373 mmethods += 1
374 end
375 end
376 end
377 end
378 self.mmodules[mpackage] = mmodules
379 self.mclasses[mpackage] = mclasses
380 self.mmethods[mpackage] = mmethods
381 self.loc[mpackage] = loc
382 self.errors[mpackage] = errors
383 self.warnings[mpackage] = warnings
384
385 #score += mmodules.score
386 score += mclasses.score
387 score += mmethods.score
388 score += loc.score
389
390 self.score[mpackage] = score.to_i
391 end
392
393 # Collect more information on a package using the `git` tool.
394 fun git_info(mpackage: MPackage)
395 do
396 var ini = mpackage.ini
397 if ini == null then return
398
399 # TODO use real git info
400 #var repo = ini.get_or_null("upstream.git")
401 #var branch = ini.get_or_null("upstream.git.branch")
402 #var directory = ini.get_or_null("upstream.git.directory")
403
404 var dirpath = mpackage.root.filepath
405 if dirpath == null then return
406
407 # Collect commits info
408 var res = git_run("log", "--no-merges", "--follow", "--pretty=tformat:%ad;%aN <%aE>", "--", dirpath)
409 var contributors = new Counter[String]
410 var commits = res.split("\n")
411 if commits.not_empty and commits.last == "" then commits.pop
412 self.commits[mpackage] = commits.length
413 for l in commits do
414 var s = l.split_once_on(';')
415 if s.length != 2 or s.last == "" then continue
416
417 # Collect date of last and first commit
418 if mpackage.last_date == null then mpackage.last_date = s.first
419 mpackage.first_date = s.first
420
421 # Count contributors
422 contributors.inc(s.last)
423 end
424 for c in contributors.sort.reverse_iterator do
425 register_contrib(c, mpackage)
426 end
427
428 end
429 end
430
431 # Execute a git command and return the result
432 fun git_run(command: String...): String
433 do
434 # print "git {command.join(" ")}"
435 var p = new ProcessReader("git", command...)
436 var res = p.read_all
437 p.close
438 p.wait
439 return res
440 end