Merge: contrib: apply nitpackage --gen-ini
[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 # --check-man
44 var opt_check_man = new OptionBool("Check manpages files", "--check-man")
45
46 # --gen-man
47 var opt_gen_man = new OptionBool("Generate manpages files", "--gen-man")
48
49 redef init do
50 super
51 option_context.add_option(opt_expand, opt_force)
52 option_context.add_option(opt_check_ini, opt_gen_ini)
53 option_context.add_option(opt_check_makefile, opt_gen_makefile)
54 option_context.add_option(opt_check_man, opt_gen_man)
55 end
56 end
57
58 private class NitPackagePhase
59 super Phase
60
61 redef fun process_mainmodule(mainmodule, mmodules) do
62 var mpackages = extract_mpackages(mmodules)
63 for mpackage in mpackages do
64
65 # Fictive and buggy packages are ignored
66 if not mpackage.has_source then
67 toolcontext.warning(mpackage.location, "no-source",
68 "Warning: `{mpackage}` has no source file")
69 continue
70 end
71
72 # Check package INI files
73 if toolcontext.opt_check_ini.value then
74 mpackage.check_ini(toolcontext)
75 continue
76 end
77
78 # Check package Makefiles
79 if toolcontext.opt_check_makefile.value then
80 mpackage.check_makefile(toolcontext, mainmodule)
81 continue
82 end
83
84 # Check manpages
85 if toolcontext.opt_check_man.value then
86 mpackage.check_man(toolcontext, mainmodule)
87 continue
88 end
89
90 # Expand packages
91 if toolcontext.opt_expand.value and not mpackage.is_expanded then
92 var path = mpackage.expand
93 toolcontext.info("{mpackage} moved to {path}", 0)
94 end
95 if not mpackage.is_expanded then
96 toolcontext.warning(mpackage.location, "no-dir",
97 "Warning: `{mpackage}` has no package directory")
98 continue
99 end
100
101 # Create INI file
102 if toolcontext.opt_gen_ini.value then
103 if not mpackage.has_ini or toolcontext.opt_force.value then
104 var path = mpackage.gen_ini
105 toolcontext.info("generated INI file `{path}`", 0)
106 end
107 end
108
109 # Create Makefile
110 if toolcontext.opt_gen_makefile.value then
111 if not mpackage.has_makefile or toolcontext.opt_force.value then
112 var path = mpackage.gen_makefile(toolcontext.modelbuilder.model, mainmodule)
113 if path != null then
114 toolcontext.info("generated Makefile `{path}`", 0)
115 end
116 end
117 end
118
119 # Create manpages
120 if toolcontext.opt_gen_man.value then
121 mpackage.gen_man(toolcontext, mainmodule)
122 end
123 end
124 end
125
126 # Extract the list of packages from the mmodules passed as arguments
127 fun extract_mpackages(mmodules: Collection[MModule]): Collection[MPackage] do
128 var mpackages = new ArraySet[MPackage]
129 for mmodule in mmodules do
130 var mpackage = mmodule.mpackage
131 if mpackage == null then continue
132 mpackages.add mpackage
133 end
134 return mpackages.to_a
135 end
136 end
137
138 redef class MPackage
139
140 # Expand `self` in its own directory
141 private fun expand: String do
142 assert not is_expanded
143
144 var ori_path = package_path.as(not null)
145 var new_path = ori_path.dirname / name
146
147 new_path.mkdir
148 sys.system "mv {ori_path} {new_path / name}.nit"
149
150 var ini_file = "{new_path}.ini"
151 if ini_file.file_exists then
152 sys.system "mv {new_path}.ini {new_path}/package.ini"
153 end
154
155 return new_path
156 end
157
158 private var maintainer: nullable String is lazy do
159 return git_exec("git shortlog -esn . | head -n 1 | sed 's/\\s*[0-9]*\\s*//'")
160 end
161
162 private var contributors: Array[String] is lazy do
163 var contribs = git_exec("git shortlog -esn . | head -n -1 | " +
164 "sed 's/\\s*[0-9]*\\s*//'")
165 if contribs == null then return new Array[String]
166 return contribs.split("\n")
167 end
168
169 private var git_url: nullable String is lazy do
170 var git = git_exec("git remote get-url origin")
171 if git == null then return null
172 git = git.replace("git@github.com:", "https://github.com/")
173 git = git.replace("git@gitlab.com:", "https://gitlab.com/")
174 return git
175 end
176
177 private var git_dir: nullable String is lazy do
178 return git_exec("git rev-parse --show-prefix")
179 end
180
181 private var browse_url: nullable String is lazy do
182 var git = git_url
183 if git == null then return null
184 var browse = git.replace(".git", "")
185 var dir = git_dir
186 if dir == null or dir.is_empty then return browse
187 return "{browse}/tree/master/{dir}"
188 end
189
190 private var homepage_url: nullable String is lazy do
191 var git = git_url
192 if git == null then return null
193 # Special case for nit files
194 if git.has_suffix("/nit.git") then
195 return "http://nitlanguage.org"
196 end
197 return git.replace(".git", "")
198 end
199
200 private var issues_url: nullable String is lazy do
201 var git = git_url
202 if git == null then return null
203 return "{git.replace(".git", "")}/issues"
204 end
205
206 private var license: nullable String is lazy do
207 var git = git_url
208 if git == null then return null
209 # Special case for nit files
210 if git.has_suffix("/nit.git") then
211 return "Apache-2.0"
212 end
213 return null
214 end
215
216 private fun git_exec(cmd: String): nullable String do
217 var path = package_path
218 if path == null then return null
219 if not is_expanded then path = path.dirname
220 with pr = new ProcessReader("sh", "-c", "cd {path} && {cmd}") do
221 return pr.read_all.trim
222 end
223 end
224
225 private var allowed_ini_keys = [
226 "package.name", "package.desc", "package.tags", "package.license",
227 "package.maintainer", "package.more_contributors",
228 "upstream.browse", "upstream.git", "upstream.git.directory",
229 "upstream.homepage", "upstream.issues", "upstream.apk", "upstream.tryit",
230 "source.exclude"
231 ]
232
233 private fun check_ini(toolcontext: ToolContext) do
234 if not has_ini then
235 toolcontext.error(location, "No `package.ini` file for `{name}`")
236 return
237 end
238
239 var pkg_path = package_path
240 if pkg_path == null then return
241
242 var ini_path = ini_path
243 if ini_path == null then return
244
245 var ini = new ConfigTree(ini_path)
246
247 ini.check_key(toolcontext, self, "package.name", name)
248 ini.check_key(toolcontext, self, "package.desc")
249 ini.check_key(toolcontext, self, "package.tags")
250
251 # FIXME since `git reflog --follow` seems bugged
252 ini.check_key(toolcontext, self, "package.maintainer")
253 # var maint = mpackage.maintainer
254 # if maint != null then
255 # ini.check_key(toolcontext, self, "package.maintainer", maint)
256 # end
257
258 # FIXME since `git reflog --follow` seems bugged
259 # var contribs = mpackage.contributors
260 # if contribs.not_empty then
261 # ini.check_key(toolcontext, self, "package.more_contributors", contribs.join(", "))
262 # end
263
264 ini.check_key(toolcontext, self, "package.license", license)
265 ini.check_key(toolcontext, self, "upstream.browse", browse_url)
266 ini.check_key(toolcontext, self, "upstream.git", git_url)
267 ini.check_key(toolcontext, self, "upstream.git.directory", git_dir)
268 ini.check_key(toolcontext, self, "upstream.homepage", homepage_url)
269 ini.check_key(toolcontext, self, "upstream.issues", issues_url)
270
271 for key in ini.to_map.keys do
272 if not allowed_ini_keys.has(key) then
273 toolcontext.warning(location, "unknown-ini-key",
274 "Warning: ignoring unknown `{key}` key in `{ini.ini_file}`")
275 end
276 end
277 end
278
279 private fun gen_ini: String do
280 var ini_path = self.ini_path.as(not null)
281 var ini = new ConfigTree(ini_path)
282
283 ini.update_value("package.name", name)
284 ini.update_value("package.desc", "")
285 ini.update_value("package.tags", "")
286 ini.update_value("package.maintainer", maintainer)
287 ini.update_value("package.more_contributors", contributors.join(","))
288 ini.update_value("package.license", license or else "")
289
290 ini.update_value("upstream.browse", browse_url)
291 ini.update_value("upstream.git", git_url)
292 ini.update_value("upstream.git.directory", git_dir)
293 ini.update_value("upstream.homepage", homepage_url)
294 ini.update_value("upstream.issues", issues_url)
295
296 ini.save
297 return ini_path
298 end
299
300 # Makefile
301
302 # The path to `self` Makefile
303 fun makefile_path: nullable String do
304 var path = package_path
305 if path == null then return null
306 if not is_expanded then return null
307 return path / "Makefile"
308 end
309
310 # Does `self` have a Makefile?
311 fun has_makefile: Bool do
312 var makefile_path = self.makefile_path
313 if makefile_path == null then return false
314 return makefile_path.file_exists
315 end
316
317 private fun check_makefile(toolcontext: ToolContext, mainmodule: MModule) do
318 var model = toolcontext.modelbuilder.model
319 var filter = new ModelFilter(accept_example = false, accept_test = false)
320 var view = new ModelView(model, mainmodule, filter)
321
322 var cmd_bin = new CmdMains(view, mentity = self)
323 var res_bin = cmd_bin.init_command
324 if not res_bin isa CmdSuccess then return
325
326 for mmodule in cmd_bin.results.as(not null) do
327 if not mmodule isa MModule then continue
328
329 if mmodule.makefile_path == null then
330 toolcontext.warning(location, "missing-makefile",
331 "Warning: no Makefile for executable module `{mmodule.full_name}`")
332 end
333 end
334 end
335
336 private fun gen_makefile(model: Model, mainmodule: MModule): nullable String do
337 var filter = new ModelFilter(accept_example = false, accept_test = false)
338 var view = new ModelView(model, mainmodule, filter)
339
340 var pkg_path = package_path.as(not null)
341 var makefile_path = makefile_path.as(not null)
342
343 var bins = new Array[String]
344 var cmd_bin = new CmdMains(view, mentity = self)
345 var res_bin = cmd_bin.init_command
346 if res_bin isa CmdSuccess then
347 for mmodule in cmd_bin.results.as(not null) do
348 if not mmodule isa MModule then continue
349 var mmodule_makefile = mmodule.makefile_path
350 if mmodule_makefile != null and mmodule_makefile != makefile_path then continue
351
352 var file = mmodule.location.file
353 if file == null then continue
354 # Remove package path prefix
355 var bin_path = file.filename
356 if pkg_path.has_suffix("/") then
357 bin_path = bin_path.replace(pkg_path, "")
358 else
359 bin_path = bin_path.replace("{pkg_path}/", "")
360 end
361 bins.add bin_path
362 end
363 end
364
365 if bins.is_empty then return null
366
367 var make = new NitMakefile(bins)
368 make.render.write_to_file(makefile_path)
369 return makefile_path
370 end
371
372 # Manpages
373
374 # The path to `self` manpage files
375 private fun man_path: nullable String do
376 var path = package_path
377 if path == null then return null
378 if not is_expanded then return null
379 return path / "man"
380 end
381
382 # Does `self` have a manpage files?
383 private fun has_man: Bool do
384 var man_path = self.man_path
385 if man_path == null then return false
386 return man_path.file_exists
387 end
388
389 private fun check_man(toolcontext: ToolContext, mainmodule: MModule) do
390 var model = toolcontext.modelbuilder.model
391 var filter = new ModelFilter(accept_example = false, accept_test = false)
392 var view = new ModelView(model, mainmodule, filter)
393
394 var cmd = new CmdMains(view, mentity = self)
395 var res = cmd.init_command
396 if not res isa CmdSuccess then return
397
398 for mmodule in cmd.results.as(not null) do
399 if not mmodule isa MModule then continue
400 mmodule.check_man(toolcontext)
401 end
402 end
403
404 private fun gen_man(toolcontext: ToolContext, mainmodule: MModule) do
405 var model = toolcontext.modelbuilder.model
406 var filter = new ModelFilter(accept_example = false, accept_test = false)
407 var view = new ModelView(model, mainmodule, filter)
408
409 var cmd = new CmdMains(view, mentity = self)
410 var res = cmd.init_command
411 if not res isa CmdSuccess then return
412
413 var pkg_man = man_path.as(not null)
414 for mmodule in cmd.results.as(not null) do
415 if not mmodule isa MModule then continue
416 if not has_man then pkg_man.mkdir
417 mmodule.gen_man(toolcontext)
418 end
419 end
420 end
421
422 redef class MModule
423 private fun makefile_path: nullable String do
424 var file = location.file
425 if file == null then return null
426
427 var dir = file.filename.dirname
428 var makefile = (dir / "Makefile")
429 if not makefile.file_exists then return null
430
431 for line in makefile.to_path.read_lines do
432 if line.has_prefix("{name}:") then return makefile
433 end
434 return null
435 end
436
437 private fun man_path: nullable String do
438 var mpackage = self.mpackage
439 if mpackage == null then return null
440 var path = mpackage.man_path
441 if path == null then return null
442 return path / "{name}.man"
443 end
444
445 # Does `self` have a manpage?
446 private fun has_man: Bool do
447 var man_path = self.man_path
448 if man_path == null then return false
449 return man_path.file_exists
450 end
451
452 private fun make_module(toolcontext: ToolContext): Bool do
453 var mpackage = self.mpackage
454 if mpackage == null then return false
455 if not mpackage.is_expanded then return false
456
457 var pkg_path = mpackage.package_path
458 if pkg_path == null then return false
459
460 var pr = new ProcessReader("sh", "-c", "cd {pkg_path} && make -Bs bin/{name}")
461 var out = pr.read_all.trim
462 pr.close
463 pr.wait
464 if pr.status > 0 then
465 toolcontext.error(location, "unable to compile `{name}`")
466 print out
467 return false
468 end
469 return true
470 end
471
472 private fun stub_man(toolcontext: ToolContext): nullable String do
473 if not make_module(toolcontext) then return null
474 var mpackage = self.mpackage
475 if mpackage == null then return null
476 if not mpackage.is_expanded then return null
477
478 var pkg_path = mpackage.package_path
479 if pkg_path == null then return null
480
481 var pr = new ProcessReader("{pkg_path}/bin/{name}", "--stub-man")
482 var man = pr.read_all.trim
483 pr.close
484 pr.wait
485 if pr.status > 0 then
486 toolcontext.error(location, "unable to run `{pkg_path}/bin/{name} --stub-man`")
487 print man
488 return null
489 end
490 return man
491 end
492
493 private fun check_man(toolcontext: ToolContext) do
494 if not has_man then
495 toolcontext.error(location, "No manpage for bin {full_name}")
496 return
497 end
498 var man_path = self.man_path.as(not null)
499 var man = stub_man(toolcontext)
500 if man == null or man.is_empty then return
501
502 var old_man = new ManPage.from_file(self, man_path)
503 var new_man = new ManPage.from_string(self, man)
504 old_man.diff(toolcontext, new_man)
505 end
506
507 private fun gen_man(toolcontext: ToolContext) do
508 var man = stub_man(toolcontext)
509 if man == null or man.is_empty then return
510 var man_path = self.man_path
511 if man_path == null then return
512 man.write_to_file(man_path)
513 toolcontext.info("created manpage `{man_path}`", 0)
514 end
515 end
516
517 redef class ConfigTree
518 private fun check_key(toolcontext: ToolContext, mpackage: MPackage, key: String, value: nullable String) do
519 if not has_key(key) then
520 toolcontext.warning(mpackage.location, "missing-ini-key",
521 "Warning: missing `{key}` key in `{ini_file}`")
522 return
523 end
524 if self[key].as(not null).is_empty then
525 toolcontext.warning(mpackage.location, "missing-ini-value",
526 "Warning: empty `{key}` key in `{ini_file}`")
527 return
528 end
529 if value != null and self[key] != value then
530 toolcontext.warning(mpackage.location, "wrong-ini-value",
531 "Warning: wrong value for `{key}` in `{ini_file}`. " +
532 "Expected `{value}`, got `{self[key] or else ""}`")
533 end
534 end
535
536 private fun update_value(key: String, value: nullable String) do
537 if value == null then return
538 if not has_key(key) then
539 self[key] = value
540 else
541 var old_value = self[key]
542 if not value.is_empty and old_value != value then
543 self[key] = value
544 end
545 end
546 end
547 end
548
549 # A Makefile for the Nit project
550 class NitMakefile
551
552 # Nit files to compile
553 var nit_files: Array[String]
554
555 # List of rules to add in the Makefile
556 fun rules: Array[MakeRule] do
557 var rules = new Array[MakeRule]
558
559 var rule_all = new MakeRule("all", is_phony = true)
560 rules.add rule_all
561
562 for file in nit_files do
563 var bin = file.basename.strip_extension
564
565 rule_all.deps.add "bin/{bin}"
566
567 var rule = new MakeRule("bin/{bin}")
568 rule.deps.add "$(shell $(NITLS) -M {file})"
569 rule.lines.add "mkdir -p bin/"
570 rule.lines.add "$(NITC) {file} -o bin/{bin}"
571 rules.add rule
572 end
573
574 var rule_check = new MakeRule("check", is_phony = true)
575 rule_check.lines.add "$(NITUNIT) ."
576 rules.add rule_check
577
578 var rule_doc = new MakeRule("doc", is_phony = true)
579 rule_doc.lines.add "$(NITDOC) . -o doc/"
580 rules.add rule_doc
581
582 var rule_clean = new MakeRule("clean", is_phony = true)
583 if nit_files.not_empty then
584 rule_clean.lines.add "rm -rf bin/"
585 end
586 rule_clean.lines.add "rm -rf doc/"
587 rules.add rule_clean
588
589 return rules
590 end
591
592 # Render `self`
593 fun render: Writable do
594 var tpl = new Template
595 tpl.addn """
596 # This file is part of NIT ( http://www.nitlanguage.org ).
597 #
598 # Licensed under the Apache License, Version 2.0 (the "License");
599 # you may not use this file except in compliance with the License.
600 # You may obtain a copy of the License at
601 #
602 # http://www.apache.org/licenses/LICENSE-2.0
603 #
604 # Unless required by applicable law or agreed to in writing, software
605 # distributed under the License is distributed on an "AS IS" BASIS,
606 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
607 # See the License for the specific language governing permissions and
608 # limitations under the License.\n"""
609
610 if nit_files.not_empty then
611 tpl.addn "NITC ?= nitc"
612 tpl.addn "NITLS ?= nitls"
613 end
614 tpl.addn "NITUNIT ?= nitunit"
615 tpl.addn "NITDOC ?= nitdoc"
616
617 for rule in rules do
618 tpl.add "\n{rule.render.write_to_string}"
619 end
620
621 return tpl
622 end
623 end
624
625 # A rule that goes into a Makefile
626 class MakeRule
627
628 # Rule name
629 var name: String
630
631 # Is this rule a `.PHONY` one?
632 var is_phony: Bool = false is optional
633
634 # Rule dependencies
635 var deps = new Array[String]
636
637 # Rule lines
638 var lines = new Array[String]
639
640 # Render `self`
641 fun render: Writable do
642 var tpl = new Template
643 if is_phony then
644 tpl.addn ".PHONY: {name}"
645 end
646 tpl.add "{name}:"
647 if deps.not_empty then
648 tpl.add " {deps.join(" ")}"
649 end
650 tpl.add "\n"
651 for line in lines do
652 tpl.addn "\t{line}"
653 end
654 return tpl
655 end
656 end
657
658 private class ManPage
659 var mmodule: MModule
660 var name: nullable String is noinit
661 var synopsis: nullable String is noinit
662 var options = new HashMap[Array[String], String]
663
664 init from_file(mmodule: MModule, file: String) do
665 from_lines(mmodule, file.to_path.read_lines)
666 end
667
668 init from_string(mmodule: MModule, string: String) do
669 from_lines(mmodule, string.split("\n"))
670 end
671
672 init from_lines(mmodule: MModule, lines: Array[String]) do
673 init mmodule
674
675 var section = null
676 for i in [0..lines.length[ do
677 var line = lines[i]
678 if line.is_empty then continue
679
680 if line == "# NAME" then
681 section = "name"
682 continue
683 end
684 if line == "# SYNOPSIS" then
685 section = "synopsis"
686 continue
687 end
688 if line == "# OPTIONS" then
689 section = "options"
690 continue
691 end
692
693 if section == "name" and name == null then
694 name = line.trim
695 end
696 if section == "synopsis" and synopsis == null then
697 synopsis = line.trim
698 end
699 if section == "options" and line.has_prefix("###") then
700 var opts = new Array[String]
701 for opt in line.substring(3, line.length).trim.replace("`", "").split(",") do
702 opts.add opt.trim
703 end
704 var desc = ""
705 if i < lines.length - 1 then
706 desc = lines[i + 1].trim
707 end
708 options[opts] = desc
709 end
710 end
711 end
712
713 fun diff(toolcontext: ToolContext, ref: ManPage) do
714 if name != ref.name then
715 toolcontext.warning(mmodule.location, "diff-man",
716 "Warning: outdated man description. " +
717 "Expected `{ref.name or else ""}` got `{name or else ""}`.")
718 end
719 if synopsis != ref.synopsis then
720 toolcontext.warning(mmodule.location, "diff-man",
721 "Warning: outdated man synopsis. " +
722 "Expected `{ref.synopsis or else ""}` got `{synopsis or else ""}`.")
723 end
724 for name, desc in options do
725 if not ref.options.has_key(name) then
726 toolcontext.warning(mmodule.location, "diff-man",
727 "Warning: unknown man option `{name}`.`")
728 continue
729 end
730 var ref_desc = ref.options[name]
731 if desc != ref_desc then
732 toolcontext.warning(mmodule.location, "diff-man",
733 "Warning: outdated man option description. Expected `{ref_desc}` got `{desc}`.")
734 end
735 end
736 for ref_name, ref_desc in ref.options do
737 if not options.has_key(ref_name) then
738 toolcontext.warning(mmodule.location, "diff-man",
739 "Warning: missing man option `{ref_name}`.`")
740 end
741 end
742 end
743
744 redef fun to_s do
745 var tpl = new Template
746 tpl.addn "# NAME"
747 tpl.addn name or else ""
748 tpl.addn "# SYNOPSIS"
749 tpl.addn synopsis or else ""
750 tpl.addn "# OPTIONS"
751 for name, desc in options do
752 tpl.addn " * {name}: {desc}"
753 end
754 return tpl.write_to_string
755 end
756 end
757
758 # build toolcontext
759 var toolcontext = new ToolContext
760 var tpl = new Template
761 tpl.add "Usage: nitpackage [OPTION]... <file.nit>...\n"
762 tpl.add "Helpful features about packages."
763 toolcontext.tooldescription = tpl.write_to_string
764
765 # process options
766 toolcontext.process_options(args)
767 var arguments = toolcontext.option_context.rest
768
769 # build model
770 var model = new Model
771 var mbuilder = new ModelBuilder(model, toolcontext)
772 var mmodules = mbuilder.parse_full(arguments)
773
774 # process
775 if mmodules.is_empty then return
776 mbuilder.run_phases
777 toolcontext.run_global_phases(mmodules)