Merge: doc: fixed some typos and other misc. corrections
[nit.git] / src / nitpackage.nit
index 8c1ec62..7f4c5f2 100644 (file)
 module nitpackage
 
 import frontend
+import doc::commands::commands_main
 
 redef class ToolContext
+
+       # nitpackage phase
+       var nitpackage_phase: Phase = new NitPackagePhase(self, null)
+
        # --expand
        var opt_expand = new OptionBool("Move singleton packages to their own directory", "--expand")
 
-       # README handling phase
-       var readme_phase: Phase = new ReadmePhase(self, null)
+       # --check-ini
+       var opt_check_ini = new OptionBool("Check package.ini files", "--check-ini")
+
+       # --gen-ini
+       var opt_gen_ini = new OptionBool("Generate package.ini files", "--gen-ini")
+
+       # --force
+       var opt_force = new OptionBool("Force update of existing files", "-f", "--force")
+
+       # --check-makefile
+       var opt_check_makefile = new OptionBool("Check Makefile files", "--check-makefile")
+
+       # --gen-makefile
+       var opt_gen_makefile = new OptionBool("Generate Makefile files", "--gen-makefile")
+
+       # --check-man
+       var opt_check_man = new OptionBool("Check manpages files", "--check-man")
+
+       # --gen-man
+       var opt_gen_man = new OptionBool("Generate manpages files", "--gen-man")
+
+       # --check-readme
+       var opt_check_readme = new OptionBool("Check README.md files", "--check-readme")
 
        redef init do
                super
-               option_context.add_option(opt_expand)
+               option_context.add_option(opt_expand, opt_force)
+               option_context.add_option(opt_check_ini, opt_gen_ini)
+               option_context.add_option(opt_check_makefile, opt_gen_makefile)
+               option_context.add_option(opt_check_man, opt_gen_man)
+               option_context.add_option(opt_check_readme)
        end
 end
 
-private class ReadmePhase
+private class NitPackagePhase
        super Phase
 
        redef fun process_mainmodule(mainmodule, mmodules) do
                var mpackages = extract_mpackages(mmodules)
-
                for mpackage in mpackages do
 
                        # Fictive and buggy packages are ignored
@@ -45,6 +74,30 @@ private class ReadmePhase
                                continue
                        end
 
+                       # Check package INI files
+                       if toolcontext.opt_check_ini.value then
+                               mpackage.check_ini(toolcontext)
+                               continue
+                       end
+
+                       # Check package Makefiles
+                       if toolcontext.opt_check_makefile.value then
+                               mpackage.check_makefile(toolcontext, mainmodule)
+                               continue
+                       end
+
+                       # Check manpages
+                       if toolcontext.opt_check_man.value then
+                               mpackage.check_man(toolcontext, mainmodule)
+                               continue
+                       end
+
+                       # Check README.md
+                       if toolcontext.opt_check_readme.value then
+                               mpackage.check_readme(toolcontext)
+                               continue
+                       end
+
                        # Expand packages
                        if toolcontext.opt_expand.value and not mpackage.is_expanded then
                                var path = mpackage.expand
@@ -55,6 +108,29 @@ private class ReadmePhase
                                        "Warning: `{mpackage}` has no package directory")
                                continue
                        end
+
+                       # Create INI file
+                       if toolcontext.opt_gen_ini.value then
+                               if not mpackage.has_ini or toolcontext.opt_force.value then
+                                       var path = mpackage.gen_ini
+                                       toolcontext.info("generated INI file `{path}`", 0)
+                               end
+                       end
+
+                       # Create Makefile
+                       if toolcontext.opt_gen_makefile.value then
+                               if not mpackage.has_makefile or toolcontext.opt_force.value then
+                                       var path = mpackage.gen_makefile(toolcontext.modelbuilder.model, mainmodule)
+                                       if path != null then
+                                               toolcontext.info("generated Makefile `{path}`", 0)
+                                       end
+                               end
+                       end
+
+                       # Create manpages
+                       if toolcontext.opt_gen_man.value then
+                               mpackage.gen_man(toolcontext, mainmodule)
+                       end
                end
        end
 
@@ -89,6 +165,608 @@ redef class MPackage
 
                return new_path
        end
+
+       private var maintainer: nullable String is lazy do
+               return git_exec("git shortlog -esn . | head -n 1 | sed 's/\\s*[0-9]*\\s*//'")
+       end
+
+       private var contributors: Array[String] is lazy do
+               var contribs = git_exec("git shortlog -esn . | head -n -1 | " +
+                       "sed 's/\\s*[0-9]*\\s*//'")
+               if contribs == null then return new Array[String]
+               return contribs.split("\n")
+       end
+
+       private var git_url: nullable String is lazy do
+               var git = git_exec("git remote get-url origin")
+               if git == null then return null
+               git = git.replace("git@github.com:", "https://github.com/")
+               git = git.replace("git@gitlab.com:", "https://gitlab.com/")
+               return git
+       end
+
+       private var git_dir: nullable String is lazy do
+               return git_exec("git rev-parse --show-prefix")
+       end
+
+       private var browse_url: nullable String is lazy do
+               var git = git_url
+               if git == null then return null
+               var browse = git.replace(".git", "")
+               var dir = git_dir
+               if dir == null or dir.is_empty then return browse
+               return "{browse}/tree/master/{dir}"
+       end
+
+       private var homepage_url: nullable String is lazy do
+               var git = git_url
+               if git == null then return null
+               # Special case for nit files
+               if git.has_suffix("/nit.git") then
+                       return "http://nitlanguage.org"
+               end
+               return git.replace(".git", "")
+       end
+
+       private var issues_url: nullable String is lazy do
+               var git = git_url
+               if git == null then return null
+               return "{git.replace(".git", "")}/issues"
+       end
+
+       private var license: nullable String is lazy do
+               var git = git_url
+               if git == null then return null
+               # Special case for nit files
+               if git.has_suffix("/nit.git") then
+                       return "Apache-2.0"
+               end
+               return null
+       end
+
+       private fun git_exec(cmd: String): nullable String do
+               var path = package_path
+               if path == null then return null
+               if not is_expanded then path = path.dirname
+               with pr = new ProcessReader("sh", "-c", "cd {path} && {cmd}") do
+                       return pr.read_all.trim
+               end
+       end
+
+       private var allowed_ini_keys = [
+               "package.name", "package.desc", "package.tags", "package.license",
+               "package.maintainer", "package.more_contributors",
+               "upstream.browse", "upstream.git", "upstream.git.directory",
+               "upstream.homepage", "upstream.issues", "upstream.apk", "upstream.tryit",
+               "source.exclude"
+               ]
+
+       private fun check_ini(toolcontext: ToolContext) do
+               if not has_ini then
+                       toolcontext.error(location, "No `package.ini` file for `{name}`")
+                       return
+               end
+
+               var pkg_path = package_path
+               if pkg_path == null then return
+
+               var ini_path = ini_path
+               if ini_path == null then return
+
+               var ini = new IniFile.from_file(ini_path)
+
+               ini.check_key(ini_path, toolcontext, self, "package.name", name)
+               ini.check_key(ini_path, toolcontext, self, "package.desc")
+               ini.check_key(ini_path, toolcontext, self, "package.tags")
+
+               # FIXME since `git reflog --follow` seems bugged
+               ini.check_key(ini_path, toolcontext, self, "package.maintainer")
+               # var maint = mpackage.maintainer
+               # if maint != null then
+                       # ini.check_key(toolcontext, self, "package.maintainer", maint)
+               # end
+
+               # FIXME since `git reflog --follow` seems bugged
+               # var contribs = mpackage.contributors
+               # if contribs.not_empty then
+                       # ini.check_key(toolcontext, self, "package.more_contributors", contribs.join(", "))
+               # end
+
+               ini.check_key(ini_path, toolcontext, self, "package.license", license)
+               ini.check_key(ini_path, toolcontext, self, "upstream.browse", browse_url)
+               ini.check_key(ini_path, toolcontext, self, "upstream.git", git_url)
+               ini.check_key(ini_path, toolcontext, self, "upstream.git.directory", git_dir)
+               ini.check_key(ini_path, toolcontext, self, "upstream.homepage", homepage_url)
+               ini.check_key(ini_path, toolcontext, self, "upstream.issues", issues_url)
+
+               for key in ini.flatten.keys do
+                       if not allowed_ini_keys.has(key) then
+                               toolcontext.warning(location, "unknown-ini-key",
+                                       "Warning: ignoring unknown `{key}` key in `{ini_path}`")
+                       end
+               end
+       end
+
+       private fun gen_ini: String do
+               var ini_path = self.ini_path.as(not null)
+               var ini = new IniFile.from_file(ini_path)
+
+               ini.update_value("package.name", name)
+               ini.update_value("package.desc", "")
+               ini.update_value("package.tags", "")
+               ini.update_value("package.maintainer", maintainer)
+               ini.update_value("package.more_contributors", contributors.join(","))
+               ini.update_value("package.license", license or else "")
+
+               ini.update_value("upstream.browse", browse_url)
+               ini.update_value("upstream.git", git_url)
+               ini.update_value("upstream.git.directory", git_dir)
+               ini.update_value("upstream.homepage", homepage_url)
+               ini.update_value("upstream.issues", issues_url)
+
+               ini.write_to_file(ini_path)
+               return ini_path
+       end
+
+       # Makefile
+
+       # The path to `self` Makefile
+       fun makefile_path: nullable String do
+               var path = package_path
+               if path == null then return null
+               if not is_expanded then return null
+               return path / "Makefile"
+       end
+
+       # Does `self` have a Makefile?
+       fun has_makefile: Bool do
+               var makefile_path = self.makefile_path
+               if makefile_path == null then return false
+               return makefile_path.file_exists
+       end
+
+       private fun check_makefile(toolcontext: ToolContext, mainmodule: MModule) do
+               var model = toolcontext.modelbuilder.model
+               var filter = new ModelFilter(accept_example = false, accept_test = false)
+
+               var cmd_bin = new CmdMains(model, filter, mentity = self)
+               var res_bin = cmd_bin.init_command
+               if not res_bin isa CmdSuccess then return
+
+               for mmodule in cmd_bin.results.as(not null) do
+                       if not mmodule isa MModule then continue
+
+                       if mmodule.makefile_path == null then
+                               toolcontext.warning(location, "missing-makefile",
+                                       "Warning: no Makefile for executable module `{mmodule.full_name}`")
+                       end
+               end
+       end
+
+       private fun gen_makefile(model: Model, mainmodule: MModule): nullable String do
+               var filter = new ModelFilter(accept_example = false, accept_test = false)
+
+               var pkg_path = package_path.as(not null)
+               var makefile_path = makefile_path.as(not null)
+
+               var bins = new Array[String]
+               var cmd_bin = new CmdMains(model, filter, mentity = self)
+               var res_bin = cmd_bin.init_command
+               if res_bin isa CmdSuccess then
+                       for mmodule in cmd_bin.results.as(not null) do
+                               if not mmodule isa MModule then continue
+                               var mmodule_makefile = mmodule.makefile_path
+                               if mmodule_makefile != null and mmodule_makefile != makefile_path then continue
+
+                               var file = mmodule.location.file
+                               if file == null then continue
+                               # Remove package path prefix
+                               var bin_path = file.filename
+                               if pkg_path.has_suffix("/") then
+                                       bin_path = bin_path.replace(pkg_path, "")
+                               else
+                                       bin_path = bin_path.replace("{pkg_path}/", "")
+                               end
+                               bins.add bin_path
+                       end
+               end
+
+               if  bins.is_empty then return null
+
+               var make = new NitMakefile(bins)
+               make.render.write_to_file(makefile_path)
+               return makefile_path
+       end
+
+       # Manpages
+
+       # The path to `self` manpage files
+       private fun man_path: nullable String do
+               var path = package_path
+               if path == null then return null
+               if not is_expanded then return null
+               return path / "man"
+       end
+
+       # Does `self` have a manpage files?
+       private fun has_man: Bool do
+               var man_path = self.man_path
+               if man_path == null then return false
+               return man_path.file_exists
+       end
+
+       private fun check_man(toolcontext: ToolContext, mainmodule: MModule) do
+               var model = toolcontext.modelbuilder.model
+               var filter = new ModelFilter(accept_example = false, accept_test = false)
+               var cmd = new CmdMains(model, filter, mentity = self)
+               var res = cmd.init_command
+               if not res isa CmdSuccess then return
+
+               for mmodule in cmd.results.as(not null) do
+                       if not mmodule isa MModule then continue
+                       mmodule.check_man(toolcontext)
+               end
+       end
+
+       private fun gen_man(toolcontext: ToolContext, mainmodule: MModule) do
+               var model = toolcontext.modelbuilder.model
+               var filter = new ModelFilter(accept_example = false, accept_test = false)
+               var cmd = new CmdMains(model, filter, mentity = self)
+               var res = cmd.init_command
+               if not res isa CmdSuccess then return
+
+               var pkg_man = man_path.as(not null)
+               for mmodule in cmd.results.as(not null) do
+                       if not mmodule isa MModule then continue
+                       if not has_man then pkg_man.mkdir
+                       mmodule.gen_man(toolcontext)
+               end
+       end
+
+       # README
+
+       private fun check_readme(toolcontext: ToolContext) do
+               if not has_readme then
+                       toolcontext.error(location, "No `README.md` file for `{name}`")
+                       return
+               end
+       end
+end
+
+redef class MModule
+       private fun makefile_path: nullable String do
+               var file = location.file
+               if file == null then return null
+
+               var dir = file.filename.dirname
+               var makefile = (dir / "Makefile")
+               if not makefile.file_exists then return null
+
+               for line in makefile.to_path.read_lines do
+                       if line.has_prefix("{name}:") then return makefile
+               end
+               return null
+       end
+
+       private fun man_path: nullable String do
+               var mpackage = self.mpackage
+               if mpackage == null then return null
+               var path = mpackage.man_path
+               if path == null then return null
+               return path / "{name}.man"
+       end
+
+       # Does `self` have a manpage?
+       private fun has_man: Bool do
+               var man_path = self.man_path
+               if man_path == null then return false
+               return man_path.file_exists
+       end
+
+       private fun make_module(toolcontext: ToolContext): Bool do
+               var mpackage = self.mpackage
+               if mpackage == null then return false
+               if not mpackage.is_expanded then return false
+
+               var pkg_path = mpackage.package_path
+               if pkg_path == null then return false
+
+               var pr = new ProcessReader("sh", "-c", "cd {pkg_path} && make -Bs bin/{name}")
+               var out = pr.read_all.trim
+               pr.close
+               pr.wait
+               if pr.status > 0 then
+                       toolcontext.error(location, "unable to compile `{name}`")
+                       print out
+                       return false
+               end
+               return true
+       end
+
+       private fun stub_man(toolcontext: ToolContext): nullable String do
+               if not make_module(toolcontext) then return null
+               var mpackage = self.mpackage
+               if mpackage == null then return null
+               if not mpackage.is_expanded then return null
+
+               var pkg_path = mpackage.package_path
+               if pkg_path == null then return null
+
+               var pr = new ProcessReader("{pkg_path}/bin/{name}", "--stub-man")
+               var man = pr.read_all.trim
+               pr.close
+               pr.wait
+               if pr.status > 0 then
+                       toolcontext.error(location, "unable to run `{pkg_path}/bin/{name} --stub-man`")
+                       print man
+                       return null
+               end
+               return man
+       end
+
+       private fun check_man(toolcontext: ToolContext) do
+               if not has_man then
+                       toolcontext.error(location, "No manpage for bin {full_name}")
+                       return
+               end
+               var man_path = self.man_path.as(not null)
+               var man = stub_man(toolcontext)
+               if man == null or man.is_empty then return
+
+               var old_man = new ManPage.from_file(self, man_path)
+               var new_man = new ManPage.from_string(self, man)
+               old_man.diff(toolcontext, new_man)
+       end
+
+       private fun gen_man(toolcontext: ToolContext) do
+               var man = stub_man(toolcontext)
+               if man == null or man.is_empty then return
+               var man_path = self.man_path
+               if man_path == null then return
+               man.write_to_file(man_path)
+               toolcontext.info("created manpage `{man_path}`", 0)
+       end
+end
+
+redef class IniFile
+       private fun check_key(ini_file: String, toolcontext: ToolContext, mpackage: MPackage, key: String, value: nullable String) do
+               if not has_key(key) then
+                       toolcontext.warning(mpackage.location, "missing-ini-key",
+                               "Warning: missing `{key}` key in `{ini_file}`")
+                       return
+               end
+               if self[key].as(not null).is_empty then
+                       toolcontext.warning(mpackage.location, "missing-ini-value",
+                               "Warning: empty `{key}` key in `{ini_file}`")
+                       return
+               end
+               if value != null and self[key] != value then
+                       toolcontext.warning(mpackage.location, "wrong-ini-value",
+                               "Warning: wrong value for `{key}` in `{ini_file}`. " +
+                               "Expected `{value}`, got `{self[key] or else ""}`")
+               end
+       end
+
+       private fun update_value(key: String, value: nullable String) do
+               if value == null then return
+               if not has_key(key) then
+                       self[key] = value
+               else
+                       var old_value = self[key]
+                       if not value.is_empty and old_value != value then
+                               self[key] = value
+                       end
+               end
+       end
+end
+
+# A Makefile for the Nit project
+class NitMakefile
+
+       # Nit files to compile
+       var nit_files: Array[String]
+
+       # List of rules to add in the Makefile
+       fun rules: Array[MakeRule] do
+               var rules = new Array[MakeRule]
+
+               var rule_all = new MakeRule("all", is_phony = true)
+               rules.add rule_all
+
+               for file in nit_files do
+                       var bin = file.basename.strip_extension
+
+                       rule_all.deps.add "bin/{bin}"
+
+                       var rule = new MakeRule("bin/{bin}")
+                       rule.deps.add "$(shell $(NITLS) -M {file})"
+                       rule.lines.add "mkdir -p bin/"
+                       rule.lines.add "$(NITC) {file} -o bin/{bin}"
+                       rules.add rule
+               end
+
+               var rule_check = new MakeRule("check", is_phony = true)
+               rule_check.lines.add "$(NITUNIT) ."
+               rules.add rule_check
+
+               var rule_doc = new MakeRule("doc", is_phony = true)
+               rule_doc.lines.add "$(NITDOC) . -o doc/"
+               rules.add rule_doc
+
+               var rule_clean = new MakeRule("clean", is_phony = true)
+               if nit_files.not_empty then
+                       rule_clean.lines.add "rm -rf bin/"
+               end
+               rule_clean.lines.add "rm -rf doc/"
+               rules.add rule_clean
+
+               return rules
+       end
+
+       # Render `self`
+       fun render: Writable do
+               var tpl = new Template
+               tpl.addn """
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.\n"""
+
+               if nit_files.not_empty then
+                       tpl.addn "NITC ?= nitc"
+                       tpl.addn "NITLS ?= nitls"
+               end
+               tpl.addn "NITUNIT ?= nitunit"
+               tpl.addn "NITDOC ?= nitdoc"
+
+               for rule in rules do
+                       tpl.add "\n{rule.render.write_to_string}"
+               end
+
+               return tpl
+       end
+end
+
+# A rule that goes into a Makefile
+class MakeRule
+
+       # Rule name
+       var name: String
+
+       # Is this rule a `.PHONY` one?
+       var is_phony: Bool = false is optional
+
+       # Rule dependencies
+       var deps = new Array[String]
+
+       # Rule lines
+       var lines = new Array[String]
+
+       # Render `self`
+       fun render: Writable do
+               var tpl = new Template
+               if is_phony then
+                       tpl.addn ".PHONY: {name}"
+               end
+               tpl.add "{name}:"
+               if deps.not_empty then
+                       tpl.add " {deps.join(" ")}"
+               end
+               tpl.add "\n"
+               for line in lines do
+                       tpl.addn "\t{line}"
+               end
+               return tpl
+       end
+end
+
+private class ManPage
+       var mmodule: MModule
+       var name: nullable String is noinit
+       var synopsis: nullable String is noinit
+       var options = new HashMap[Array[String], String]
+
+       init from_file(mmodule: MModule, file: String) do
+               from_lines(mmodule, file.to_path.read_lines)
+       end
+
+       init from_string(mmodule: MModule, string: String) do
+               from_lines(mmodule, string.split("\n"))
+       end
+
+       init from_lines(mmodule: MModule, lines: Array[String]) do
+               init mmodule
+
+               var section = null
+               for i in [0..lines.length[ do
+                       var line = lines[i]
+                       if line.is_empty then continue
+
+                       if line == "# NAME" then
+                               section = "name"
+                               continue
+                       end
+                       if line == "# SYNOPSIS" then
+                               section = "synopsis"
+                               continue
+                       end
+                       if line == "# OPTIONS" then
+                               section = "options"
+                               continue
+                       end
+
+                       if section == "name" and name == null then
+                               name = line.trim
+                       end
+                       if section == "synopsis" and synopsis == null then
+                               synopsis = line.trim
+                       end
+                       if section == "options" and line.has_prefix("###") then
+                               var opts = new Array[String]
+                               for opt in line.substring(3, line.length).trim.replace("`", "").split(",") do
+                                       opts.add opt.trim
+                               end
+                               var desc = ""
+                               if i < lines.length - 1 then
+                                       desc = lines[i + 1].trim
+                               end
+                               options[opts] = desc
+                       end
+               end
+       end
+
+       fun diff(toolcontext: ToolContext, ref: ManPage) do
+               if name != ref.name then
+                       toolcontext.warning(mmodule.location, "diff-man",
+                               "Warning: outdated man description. " +
+                               "Expected `{ref.name or else ""}` got `{name or else ""}`.")
+               end
+               if synopsis != ref.synopsis then
+                       toolcontext.warning(mmodule.location, "diff-man",
+                               "Warning: outdated man synopsis. " +
+                               "Expected `{ref.synopsis or else ""}` got `{synopsis or else ""}`.")
+               end
+               for name, desc in options do
+                       if not ref.options.has_key(name) then
+                               toolcontext.warning(mmodule.location, "diff-man",
+                                       "Warning: unknown man option `{name}`.`")
+                               continue
+                       end
+                       var ref_desc = ref.options[name]
+                       if desc != ref_desc then
+                               toolcontext.warning(mmodule.location, "diff-man",
+                                       "Warning: outdated man option description. Expected `{ref_desc}` got `{desc}`.")
+                       end
+               end
+               for ref_name, ref_desc in ref.options do
+                       if not options.has_key(ref_name) then
+                               toolcontext.warning(mmodule.location, "diff-man",
+                                       "Warning: missing man option `{ref_name}`.`")
+                       end
+               end
+       end
+
+       redef fun to_s do
+               var tpl = new Template
+               tpl.addn "# NAME"
+               tpl.addn name or else ""
+               tpl.addn "# SYNOPSIS"
+               tpl.addn synopsis or else ""
+               tpl.addn "# OPTIONS"
+               for name, desc in options do
+                       tpl.addn " * {name}: {desc}"
+               end
+               return tpl.write_to_string
+       end
 end
 
 # build toolcontext