nitpackage: generate and check Makefiles
[nit.git] / src / nitpackage.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 # Helpful features about packages
16 module nitpackage
17
18 import frontend
19 import doc::commands::commands_main
20
21 redef class ToolContext
22 # --expand
23 var opt_expand = new OptionBool("Move singleton packages to their own directory", "--expand")
24
25 # --check-ini
26 var opt_check_ini = new OptionBool("Check package.ini files", "--check-ini")
27
28 # --gen-ini
29 var opt_gen_ini = new OptionBool("Generate package.ini files", "--gen-ini")
30
31 # --force
32 var opt_force = new OptionBool("Force update of existing files", "-f", "--force")
33
34 # --check-makefile
35 var opt_check_makefile = new OptionBool("Check Makefile files", "--check-makefile")
36
37 # --gen-makefile
38 var opt_gen_makefile = new OptionBool("Generate Makefile files", "--gen-makefile")
39
40 # nitpackage phase
41 var nitpackage_phase: Phase = new NitPackagePhase(self, null)
42
43 redef init do
44 super
45 option_context.add_option(opt_expand, opt_force)
46 option_context.add_option(opt_check_ini, opt_gen_ini)
47 option_context.add_option(opt_check_makefile, opt_gen_makefile)
48 end
49 end
50
51 private class NitPackagePhase
52 super Phase
53
54 redef fun process_mainmodule(mainmodule, mmodules) do
55 var mpackages = extract_mpackages(mmodules)
56 for mpackage in mpackages do
57
58 # Fictive and buggy packages are ignored
59 if not mpackage.has_source then
60 toolcontext.warning(mpackage.location, "no-source",
61 "Warning: `{mpackage}` has no source file")
62 continue
63 end
64
65 # Check package INI files
66 if toolcontext.opt_check_ini.value then
67 mpackage.check_ini(toolcontext)
68 continue
69 end
70
71 # Check package Makefiles
72 if toolcontext.opt_check_makefile.value then
73 mpackage.check_makefile(toolcontext, mainmodule)
74 continue
75 end
76
77 # Expand packages
78 if toolcontext.opt_expand.value and not mpackage.is_expanded then
79 var path = mpackage.expand
80 toolcontext.info("{mpackage} moved to {path}", 0)
81 end
82 if not mpackage.is_expanded then
83 toolcontext.warning(mpackage.location, "no-dir",
84 "Warning: `{mpackage}` has no package directory")
85 continue
86 end
87
88 # Create INI file
89 if toolcontext.opt_gen_ini.value then
90 if not mpackage.has_ini or toolcontext.opt_force.value then
91 var path = mpackage.gen_ini
92 toolcontext.info("generated INI file `{path}`", 0)
93 end
94 end
95
96 # Create Makefile
97 if toolcontext.opt_gen_makefile.value then
98 if not mpackage.has_makefile or toolcontext.opt_force.value then
99 var path = mpackage.gen_makefile(toolcontext.modelbuilder.model, mainmodule)
100 if path != null then
101 toolcontext.info("generated Makefile `{path}`", 0)
102 end
103 end
104 end
105 end
106 end
107
108 # Extract the list of packages from the mmodules passed as arguments
109 fun extract_mpackages(mmodules: Collection[MModule]): Collection[MPackage] do
110 var mpackages = new ArraySet[MPackage]
111 for mmodule in mmodules do
112 var mpackage = mmodule.mpackage
113 if mpackage == null then continue
114 mpackages.add mpackage
115 end
116 return mpackages.to_a
117 end
118 end
119
120 redef class MPackage
121
122 # Expand `self` in its own directory
123 private fun expand: String do
124 assert not is_expanded
125
126 var ori_path = package_path.as(not null)
127 var new_path = ori_path.dirname / name
128
129 new_path.mkdir
130 sys.system "mv {ori_path} {new_path / name}.nit"
131
132 var ini_file = "{new_path}.ini"
133 if ini_file.file_exists then
134 sys.system "mv {new_path}.ini {new_path}/package.ini"
135 end
136
137 return new_path
138 end
139
140 private var maintainer: nullable String is lazy do
141 return git_exec("git shortlog -esn . | head -n 1 | sed 's/\\s*[0-9]*\\s*//'")
142 end
143
144 private var contributors: Array[String] is lazy do
145 var contribs = git_exec("git shortlog -esn . | head -n -1 | " +
146 "sed 's/\\s*[0-9]*\\s*//'")
147 if contribs == null then return new Array[String]
148 return contribs.split("\n")
149 end
150
151 private var git_url: nullable String is lazy do
152 var git = git_exec("git remote get-url origin")
153 if git == null then return null
154 git = git.replace("git@github.com:", "https://github.com/")
155 git = git.replace("git@gitlab.com:", "https://gitlab.com/")
156 return git
157 end
158
159 private var git_dir: nullable String is lazy do
160 return git_exec("git rev-parse --show-prefix")
161 end
162
163 private var browse_url: nullable String is lazy do
164 var git = git_url
165 if git == null then return null
166 var browse = git.replace(".git", "")
167 var dir = git_dir
168 if dir == null or dir.is_empty then return browse
169 return "{browse}/tree/master/{dir}"
170 end
171
172 private var homepage_url: nullable String is lazy do
173 var git = git_url
174 if git == null then return null
175 # Special case for nit files
176 if git.has_suffix("/nit.git") then
177 return "http://nitlanguage.org"
178 end
179 return git.replace(".git", "")
180 end
181
182 private var issues_url: nullable String is lazy do
183 var git = git_url
184 if git == null then return null
185 return "{git.replace(".git", "")}/issues"
186 end
187
188 private var license: nullable String is lazy do
189 var git = git_url
190 if git == null then return null
191 # Special case for nit files
192 if git.has_suffix("/nit.git") then
193 return "Apache-2.0"
194 end
195 return null
196 end
197
198 private fun git_exec(cmd: String): nullable String do
199 var path = package_path
200 if path == null then return null
201 if not is_expanded then path = path.dirname
202 with pr = new ProcessReader("sh", "-c", "cd {path} && {cmd}") do
203 return pr.read_all.trim
204 end
205 end
206
207 private var allowed_ini_keys = [
208 "package.name", "package.desc", "package.tags", "package.license",
209 "package.maintainer", "package.more_contributors",
210 "upstream.browse", "upstream.git", "upstream.git.directory",
211 "upstream.homepage", "upstream.issues"
212 ]
213
214 private fun check_ini(toolcontext: ToolContext) do
215 if not has_ini then
216 toolcontext.error(location, "No `package.ini` file for `{name}`")
217 return
218 end
219
220 var pkg_path = package_path
221 if pkg_path == null then return
222
223 var ini_path = ini_path
224 if ini_path == null then return
225
226 var ini = new ConfigTree(ini_path)
227
228 ini.check_key(toolcontext, self, "package.name", name)
229 ini.check_key(toolcontext, self, "package.desc")
230 ini.check_key(toolcontext, self, "package.tags")
231
232 # FIXME since `git reflog --follow` seems bugged
233 ini.check_key(toolcontext, self, "package.maintainer")
234 # var maint = mpackage.maintainer
235 # if maint != null then
236 # ini.check_key(toolcontext, self, "package.maintainer", maint)
237 # end
238
239 # FIXME since `git reflog --follow` seems bugged
240 # var contribs = mpackage.contributors
241 # if contribs.not_empty then
242 # ini.check_key(toolcontext, self, "package.more_contributors", contribs.join(", "))
243 # end
244
245 ini.check_key(toolcontext, self, "package.license", license)
246 ini.check_key(toolcontext, self, "upstream.browse", browse_url)
247 ini.check_key(toolcontext, self, "upstream.git", git_url)
248 ini.check_key(toolcontext, self, "upstream.git.directory", git_dir)
249 ini.check_key(toolcontext, self, "upstream.homepage", homepage_url)
250 ini.check_key(toolcontext, self, "upstream.issues", issues_url)
251
252 for key in ini.to_map.keys do
253 if not allowed_ini_keys.has(key) then
254 toolcontext.warning(location, "unknown-ini-key",
255 "Warning: ignoring unknown `{key}` key in `{ini.ini_file}`")
256 end
257 end
258 end
259
260 private fun gen_ini: String do
261 var ini_path = self.ini_path.as(not null)
262 var ini = new ConfigTree(ini_path)
263
264 ini.update_value("package.name", name)
265 ini.update_value("package.desc", "")
266 ini.update_value("package.tags", "")
267 ini.update_value("package.maintainer", maintainer)
268 ini.update_value("package.more_contributors", contributors.join(","))
269 ini.update_value("package.license", license or else "")
270
271 ini.update_value("upstream.browse", browse_url)
272 ini.update_value("upstream.git", git_url)
273 ini.update_value("upstream.git.directory", git_dir)
274 ini.update_value("upstream.homepage", homepage_url)
275 ini.update_value("upstream.issues", issues_url)
276
277 ini.save
278 return ini_path
279 end
280
281 # Makefile
282
283 # The path to `self` Makefile
284 fun makefile_path: nullable String do
285 var path = package_path
286 if path == null then return null
287 if not is_expanded then return null
288 return path / "Makefile"
289 end
290
291 # Does `self` have a Makefile?
292 fun has_makefile: Bool do
293 var makefile_path = self.makefile_path
294 if makefile_path == null then return false
295 return makefile_path.file_exists
296 end
297
298 private fun check_makefile(toolcontext: ToolContext, mainmodule: MModule) do
299 var model = toolcontext.modelbuilder.model
300 var filter = new ModelFilter(accept_example = false, accept_test = false)
301 var view = new ModelView(model, mainmodule, filter)
302
303 var cmd_bin = new CmdMains(view, mentity = self)
304 var res_bin = cmd_bin.init_command
305 if not res_bin isa CmdSuccess then return
306
307 for mmodule in cmd_bin.results.as(not null) do
308 if not mmodule isa MModule then continue
309
310 if mmodule.makefile_path == null then
311 toolcontext.warning(location, "missing-makefile",
312 "Warning: no Makefile for executable module `{mmodule.full_name}`")
313 end
314 end
315 end
316
317 private fun gen_makefile(model: Model, mainmodule: MModule): nullable String do
318 var filter = new ModelFilter(accept_example = false, accept_test = false)
319 var view = new ModelView(model, mainmodule, filter)
320
321 var pkg_path = package_path.as(not null)
322 var makefile_path = makefile_path.as(not null)
323
324 var bins = new Array[String]
325 var cmd_bin = new CmdMains(view, mentity = self)
326 var res_bin = cmd_bin.init_command
327 if res_bin isa CmdSuccess then
328 for mmodule in cmd_bin.results.as(not null) do
329 if not mmodule isa MModule then continue
330 var mmodule_makefile = mmodule.makefile_path
331 if mmodule_makefile != null and mmodule_makefile != makefile_path then continue
332
333 var file = mmodule.location.file
334 if file == null then continue
335 # Remove package path prefix
336 var bin_path = file.filename
337 if pkg_path.has_suffix("/") then
338 bin_path = bin_path.replace(pkg_path, "")
339 else
340 bin_path = bin_path.replace("{pkg_path}/", "")
341 end
342 bins.add bin_path
343 end
344 end
345
346 if bins.is_empty then return null
347
348 var make = new NitMakefile(bins)
349 make.render.write_to_file(makefile_path)
350 return makefile_path
351 end
352 end
353
354 redef class MModule
355 private fun makefile_path: nullable String do
356 var file = location.file
357 if file == null then return null
358
359 var dir = file.filename.dirname
360 var makefile = (dir / "Makefile")
361 if not makefile.file_exists then return null
362
363 for line in makefile.to_path.read_lines do
364 if line.has_prefix("{name}:") then return makefile
365 end
366 return null
367 end
368 end
369
370 redef class ConfigTree
371 private fun check_key(toolcontext: ToolContext, mpackage: MPackage, key: String, value: nullable String) do
372 if not has_key(key) then
373 toolcontext.warning(mpackage.location, "missing-ini-key",
374 "Warning: missing `{key}` key in `{ini_file}`")
375 return
376 end
377 if self[key].as(not null).is_empty then
378 toolcontext.warning(mpackage.location, "missing-ini-value",
379 "Warning: empty `{key}` key in `{ini_file}`")
380 return
381 end
382 if value != null and self[key] != value then
383 toolcontext.warning(mpackage.location, "wrong-ini-value",
384 "Warning: wrong value for `{key}` in `{ini_file}`. " +
385 "Expected `{value}`, got `{self[key] or else ""}`")
386 end
387 end
388
389 private fun update_value(key: String, value: nullable String) do
390 if value == null then return
391 if not has_key(key) then
392 self[key] = value
393 else
394 var old_value = self[key]
395 if not value.is_empty and old_value != value then
396 self[key] = value
397 end
398 end
399 end
400 end
401
402 # A Makefile for the Nit project
403 class NitMakefile
404
405 # Nit files to compile
406 var nit_files: Array[String]
407
408 # List of rules to add in the Makefile
409 fun rules: Array[MakeRule] do
410 var rules = new Array[MakeRule]
411
412 var rule_all = new MakeRule("all", is_phony = true)
413 rules.add rule_all
414
415 for file in nit_files do
416 var bin = file.basename.strip_extension
417
418 rule_all.deps.add "bin/{bin}"
419
420 var rule = new MakeRule("bin/{bin}")
421 rule.deps.add "$(shell $(NITLS) -M {file})"
422 rule.lines.add "mkdir -p bin/"
423 rule.lines.add "$(NITC) {file} -o bin/{bin}"
424 rules.add rule
425 end
426
427 var rule_check = new MakeRule("check", is_phony = true)
428 rule_check.lines.add "$(NITUNIT) ."
429 rules.add rule_check
430
431 var rule_doc = new MakeRule("doc", is_phony = true)
432 rule_doc.lines.add "$(NITDOC) . -o doc/"
433 rules.add rule_doc
434
435 var rule_clean = new MakeRule("clean", is_phony = true)
436 if nit_files.not_empty then
437 rule_clean.lines.add "rm -rf bin/"
438 end
439 rule_clean.lines.add "rm -rf doc/"
440 rules.add rule_clean
441
442 return rules
443 end
444
445 # Render `self`
446 fun render: Writable do
447 var tpl = new Template
448 tpl.addn """
449 # This file is part of NIT ( http://www.nitlanguage.org ).
450 #
451 # Licensed under the Apache License, Version 2.0 (the "License");
452 # you may not use this file except in compliance with the License.
453 # You may obtain a copy of the License at
454 #
455 # http://www.apache.org/licenses/LICENSE-2.0
456 #
457 # Unless required by applicable law or agreed to in writing, software
458 # distributed under the License is distributed on an "AS IS" BASIS,
459 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
460 # See the License for the specific language governing permissions and
461 # limitations under the License.\n"""
462
463 if nit_files.not_empty then
464 tpl.addn "NITC ?= nitc"
465 tpl.addn "NITLS ?= nitls"
466 end
467 tpl.addn "NITUNIT ?= nitunit"
468 tpl.addn "NITDOC ?= nitdoc"
469
470 for rule in rules do
471 tpl.add "\n{rule.render.write_to_string}"
472 end
473
474 return tpl
475 end
476 end
477
478 # A rule that goes into a Makefile
479 class MakeRule
480
481 # Rule name
482 var name: String
483
484 # Is this rule a `.PHONY` one?
485 var is_phony: Bool = false is optional
486
487 # Rule dependencies
488 var deps = new Array[String]
489
490 # Rule lines
491 var lines = new Array[String]
492
493 # Render `self`
494 fun render: Writable do
495 var tpl = new Template
496 if is_phony then
497 tpl.addn ".PHONY: {name}"
498 end
499 tpl.add "{name}:"
500 if deps.not_empty then
501 tpl.add " {deps.join(" ")}"
502 end
503 tpl.add "\n"
504 for line in lines do
505 tpl.addn "\t{line}"
506 end
507 return tpl
508 end
509 end
510
511 # build toolcontext
512 var toolcontext = new ToolContext
513 var tpl = new Template
514 tpl.add "Usage: nitpackage [OPTION]... <file.nit>...\n"
515 tpl.add "Helpful features about packages."
516 toolcontext.tooldescription = tpl.write_to_string
517
518 # process options
519 toolcontext.process_options(args)
520 var arguments = toolcontext.option_context.rest
521
522 # build model
523 var model = new Model
524 var mbuilder = new ModelBuilder(model, toolcontext)
525 var mmodules = mbuilder.parse_full(arguments)
526
527 # process
528 if mmodules.is_empty then return
529 mbuilder.run_phases
530 toolcontext.run_global_phases(mmodules)