=begin "alan.rb" v0.4.1 | 2021/10/26 | by Tristano Ajmone | MIT License
================================================================================
Some custom Rake helper methods for automating common Alan SDK operations that
we use across different Alan projects.
Require ALAN SDK >= Beta8 and UFT-8 encoded sources and solutions.
================================================================================
=end

require 'open3'

def CreateTranscript(storyfile, solution)
  # ----------------------------------------------------------------------------
  # Given a 'storyfile' (.a3c) and a 'solution' (.a3s), invoke ARun and generate
  # a transcript (.a3t) with the same base filename as the 'solution':
  #
  #   "<filename>.a3s" -> "<filename>.a3t"
  #
  # This method only supports UTF-8 solutions, with or without BOM. If a BOM is
  # present it will be stripped off before feeding the solution to ARun, so it
  # doesn't leak into the final transcript (as it would if redirecting the .a3s
  # to ARun via 'arun < solution.a3s > transcript.a3t')
  # ----------------------------------------------------------------------------
  TaskHeader("Generating Transcript: #{solution.ext('.a3t')}")

  target_folder = storyfile.pathmap("%d")
  a3s = solution.pathmap("%f")
  a3t = a3s.ext('.a3t')
  a3c = storyfile.pathmap("%f")

  cd "#{$repo_root}/#{target_folder}"
  sol_file = File.open(a3s, mode: "rt", encoding: "BOM|UTF-8")
  IO.popen("arun -r -u #{a3c} > #{a3t}", "r+") do |transcript|
    transcript.puts sol_file.read
  end
  sol_file.close
  cd $repo_root, verbose: false
end


def CreateADocTranscript(storyfile, solution)
  # ----------------------------------------------------------------------------
  # Given a 'storyfile' (.a3c) and a 'solution' (.a3s), generate a transcript
  # via ARun and then sanitize it into an AsciiDoc file with the same base
  # filename as the 'solution' and the ".a3t-adoc" extension:
  #
  #   "<filename>.a3s" -> "<filename>.a3t-adoc"
  #
  # This method only supports UTF-8 solutions, with or without BOM. If a BOM is
  # present it will be stripped off before feeding the solution to ARun, so it
  # doesn't leak into the final transcript.
  #
  # The transcript generated by ARun is then sanitized, line by line, in order
  # to create a well formed AsciiDoc file to be included in the documentation
  # as an example block with role 'transcript'.
  #
  # In every player input line:
  #
  #   * AsciiDoc region tags are converted to comment lines (//) after stripping
  #     the prompt (>) and ARun comment delimiter (;) so that Asciidoctor can
  #     detect the tags and to ensure the line is ignored in the final document.
  #   * Player commands are styled in emphasis.
  #   * Comments in player input are styled as a span with role 'comment'.
  #
  # In every transcript line:
  #
  #   * Hard line breaks are preserved on non-empty lines via ' +'.
  #
  # In every line which is not a player input line or a region tag comment:
  #
  #   * Special characters which are used as formatting markers in AsciiDoc are
  #     converted to predefined attributes for characters substitutions, or to
  #     named SGML entities.
  #
  # The sanitation process of player input lines assumes that the prompt is
  # always '>' (i.e. the default prompt) -- it will not work with adventures
  # using a custom defined prompt.
  # ----------------------------------------------------------------------------
  target_folder = storyfile.pathmap("%d")
  a3s = solution.pathmap("%f")
  a3t = a3s.ext('.a3t-adoc')
  a3c = storyfile.pathmap("%f")

  TaskHeader("Generating AsciiDoc Transcript: #{a3t}")

  cd "#{$repo_root}/#{target_folder}"
  sol_file = File.open(a3s, mode: "rt", encoding: "BOM|UTF-8").read
  adoc_file = File.open(a3t, mode: "w", encoding: "UTF-8")
  stdout, stderr, status = Open3.capture3("arun -r -u #{a3c}", :stdin_data=>sol_file)
  stdout.each_line(chomp: true) do |line|
    line.sub(/ *\Z/, '') # Strip trailing spaces
    # ------------------------------------------------
    # Apply AsciiDoc formatting to player input lines:
    # ------------------------------------------------
    if line.start_with? '>'
      # if line.match?(/^>\s*;\s*((?:tag|end)::.*$)/) # Region tag found!
      if m = /^>\s*;\s*((?:tag|end)::.*$)/.match(line) # Region tag found!
        # line = '// ' << line
        # line = '// ' << "#{$+}"
        line = '// ' << m[1]
      elsif m = /^>(?<spaces1> *)(?<command>[^;]*?)(?<spaces2> *)(?<comment>;.*)?$/.match(line)
        line = '&gt;' << m[:spaces1]
        line << '_' << m[:command] << '_' unless m[:command] == ''
        if m[:comment]
          line << m[:spaces2] unless m[:spaces2] == ''
          line << m[:spaces2] << '[.comment]##' << m[:comment] << '##'
        end
      end
    end
    # ---------------------------------------------
    # Sanitize every line for AsciiDoc consumption:
    # ---------------------------------------------
    # Substitute special chars which have formatting meaning in AsciiDoc,
    # but only in lines which are neither player input nor region tags:
    unless line.start_with?("//", "&gt;")
      line = line.gsub("*", "{asterisk}")    #   *   ->   {asterisk}
      line = line.gsub("\\", "{backslash}")  #   \   ->   {backslash}
      line = line.gsub(/\x60/, "{backtick}") #   `   ->   {backtick}
      line = line.gsub("\^", "{caret}")      #   ^   ->   {caret}
      line = line.gsub("+", "{plus}")        #   +   ->   {plus}
      line = line.gsub("~", "{tilde}")       #   ~   ->   {tilde}
      line = line.gsub("[", "{startsb}")     #   [   ->   {startsb}
      line = line.gsub("]", "{endsb}")       #   ]   ->   {endsb}
      line = line.gsub("_", "&lowbar;")      #   _   ->   &lowbar;
      line = line.gsub("#", "&num;")         #   #   ->   &num;
    end
    unless line.empty? || line.start_with?("//")
      line.concat(' +') # Preserve hard line breaks
    end
    adoc_file.puts line
  end
  adoc_file.close
  cd $repo_root, verbose: false
end

# @NOTE: Should the following method also create ALAN compile tasks for ALAN
#        sources which don't have a solution file associated? This could be
#        useful in situations where a folder contains adventures or manual
#        tests that don't need a transcript, and without this functionality it
#        would be hard to auto-compile them without creating duplicate tasks!

def CreateTranscriptingTasksFromFolder(target_task, target_folder, dependencies, asciidoc = false)
  # ----------------------------------------------------------------------------
  # Given a 'target_task', a 'target_folder' and a list of 'dependencies' for
  # building the adventures in the target folder (e.g. the '.i' files of a
  # library), this method will auto-magically create all the required file
  # tasks to generate a transcript (.a3t) for every solution file (.a3s) found
  # in the target folder.
  #
  # The method is smart enough to handle target folders that contain either a
  # single adventure or multiple adventures:
  #
  #   * When there's a single adventure, all solution files will be associated
  #     with the single adventure.
  #   * When there are multiple adventures, the base name of the adventure will
  #     be used to deduce which solution files should be associated with each
  #     adventure, using the "<advname>*.sol" matching pattern.
  #
  # The method will create a transcript (.a3t) file task for each solution file
  # found, where the solution file (.a3s) and its associated storyfile (.a3c)
  # are set as tasks prerequisites.
  #
  # For each source adventure (.alan) found, a storyfile (.a3c) file task will
  # also be created, with the source adventure and the 'dependencies' parameter
  # as its prerequisites.
  #
  # This method is usually employed in test suites, where 'target_folder'
  # contains one or more adventures to test a library('dependencies') using
  # multiple solution files. It can also be used by authors who wish to test
  # their work-in-progress adventure again a walk-through and solutions to test
  # edge cases, verbs responses, etc., and verify their adventure integrity as
  # they keep editing it.
  # ----------------------------------------------------------------------------
  transcript_ext = asciidoc ? '.a3t-adoc' : '.a3t'
  alan_sources = FileList["#{target_folder}/*.alan"]
  alan_sources.each do |alan_src|
    storyfile = alan_src.ext('.a3c')
    task target_task => storyfile
    file storyfile => alan_src
    file storyfile => dependencies
    if alan_sources.count() == 1
      # Single adventure: all solutions apply to it.
      solutions = FileList["#{target_folder}/*.a3s"]
    else
      # There are multiple adventures in a same folder!
      # Associate solutions using "<advname>*.sol" pattern.
      basename = storyfile.pathmap("%n")
      solutions = FileList["#{target_folder}/#{basename}*.a3s"]
    end
    solutions.each do |solution|
      transcript = solution.ext(transcript_ext)
      task target_task => transcript
      file transcript => [solution, storyfile] do |transcript|
        if !asciidoc
          CreateTranscript(storyfile, solution)
        else
          CreateADocTranscript(storyfile, solution)
        end
      end
    end
  end
end


def CreateADocTranscriptingTasksFromFolder(target_task, target_folder, dependencies)
  # ----------------------------------------------------------------------------
  # Same as 'CreateTranscriptingTasksFromFolder()', but will generate tasks for
  # AsciiDoc formatted transcripts (.a3t-adoc) instead of plain '.a3t' files.
  #
  # This method is used in documentation projects that rely on dynamically
  # generated examples and transcripts in their contents, by exploiting the
  # 'include::[]' directive to import the output of their examples from a real
  # game transcript, generated at conversion time by compiling the source
  # adventure and running custom solution files against it.
  #
  # See the comments in 'CreateADocTranscript()' for more details about AsciiDoc
  # transcripts (.a3t-adoc) and their sanitation and formatting process.
  # ----------------------------------------------------------------------------
  CreateTranscriptingTasksFromFolder(target_task, target_folder, dependencies, asciidoc = true)
end

def SanitizeAndDeployAlanSources(source_file, output_file)
  # ----------------------------------------------------------------------------
  # Creates a copy of an ALAN source file where all region tag comment lines are
  # stripped away.
  # ----------------------------------------------------------------------------
  TaskHeader("Sanitizing ALAN Source: #{output_file}")
  src_file = File.open(source_file, mode: "rt", encoding: "BOM|UTF-8").read
  out_file = File.open(output_file, mode: "w", encoding: "UTF-8")
  out_file.write "\uFEFF" # Manually add BOM!
  src_file.each_line(chomp: true) do |line|
    out_file.puts line unless /^ *-- *(tag|end)::/ =~ line
  end
  out_file.close
end

def CreateSanitizeAndDeployAlanSourcesTasksFromFolder(target_task, source_folder, dest_folder)
  # ----------------------------------------------------------------------------
  # Given a 'target_task', 'source_folder' and a 'dest_folder', scan the source
  # folder for any ALAN sources (.alan, .i) whose name doesn't start with an
  # underscore, and add to 'target_task' a file task for a same-named target
  # file in 'dest_folder', with the source file as its prerequisite, where the
  # task consists in creating a copy of the source file with all region tag
  # comments stripped off:
  #
  #    <source_folder>/[^_].*\.(alan|i) => <dest_folder>/*.(alan|i)
  #
  # This method is usually employed to deploy a cleaned up versions of Alan code
  # examples from the documentation, which are made available to end users.
  #
  # Since the documentation relies on Asciidoctor 'include::' directives to
  # selectively include snippets from ALAN sources, where region tag comment
  # lines are used to mark the various snippets, a "sanitized" version of the
  # sources is provided to end users, with all region tag comments stripped off.
  #
  # Files beginning with an underscore are skipped because, by convention,
  # these are internal examples used in the documentation but not intended for
  # distribution.
  # ----------------------------------------------------------------------------
  src_files = FileList["#{source_folder}/*.alan", "#{source_folder}/*.i"].exclude("#{source_folder}/_*.*")
  src_files.each do |src_f|
    fname = src_f.pathmap("%f")
    out_f = "#{dest_folder}/#{fname}"
    task target_task => out_f
    file out_f => src_f do
      SanitizeAndDeployAlanSources(src_f, out_f)
    end
  end
end

## Rake Rules
#############

## Compile ALAN Adventures
##########################

# To allow this to work with different repositories, the path for the '-include'
# option (if any) is stored in the global variable '$alan_include', which each
# project can optionally define in its main Rakefile.
#
# '$alan_include' can contain an absolute or relative path.
#
# This is a temporary solution with limited use coverage, e.g. it won't work
# in projects using different libraries or requiring multiple include paths
# (e.g. for other extensions). Until a better solution is found, complex repos
# will have to use 'Import' statements with relative paths to work around this
# limitation, e.g.
#
#   Import '../path/somefile.i'.
#
# Instead of:
#
#   Import 'somefile.i'.

rule ".a3c" => ".alan" do |t|
  adv_src = t.source.pathmap("%f")
  adv_dir = t.source.pathmap("%d")
  inc_opt = " -include #{$alan_include}" if $alan_include

  TaskHeader("Compiling: #{t.source}")

  cd "#{$repo_root}/#{adv_dir}"
  begin
    alan_cmd = "alan#{inc_opt} #{adv_src}"
    puts alan_cmd
    stdout, stderr, status = Open3.capture3(alan_cmd)
    raise unless status.success?
  rescue
    our_msg = "The ALAN compiler reported the following errors."
    PrintTaskFailureMessage(our_msg, stdout) # ALAN logs errors on stdout!
    # Abort Rake execution with error description:
    raise "ALAN compilation failed: #{t.source}"
  ensure
    cd $repo_root, verbose: false
  end
end
