module GtnLinter

Linting functions for the GTN

Constants

ALLOWED_SHORT_IDS

Public Class Methods

bib_missing_mandatory_fields(bib) click to toggle source
# File bin/lint.rb, line 999
def self.bib_missing_mandatory_fields(bib)
  results = []
  bib.each do |x|
    begin
      doi = x.doi
    rescue StandardError
      doi = nil
    end

    begin
      url = x.url
    rescue StandardError
      url = nil
    end

    results.push([x.key, 'Missing both a DOI and a URL. Please add one of the two.']) if doi.nil? && url.nil?

    begin
      x.title
      results.push([x.key, 'This entry is missing a title attribute. Please add it.']) if !x.title
    rescue StandardError
      results.push([x.key, 'This entry is missing a title attribute. Please add it.'])
    end
  end
  results
end
check_bad_cite(contents) click to toggle source
# File bin/lint.rb, line 329
def self.check_bad_cite(contents)
  find_matching_texts(contents, /{%\s*cite\s+([^%]*)\s*%}/i)
    .map do |idx, _text, selected|
    citation_key = selected[1].strip
    if citation_library[citation_key].nil?
      ReviewDogEmitter.error(
        path: @path,
        idx: idx,
        match_start: selected.begin(0),
        match_end: selected.end(0),
        replacement: nil,
        message: "The citation (#{citation_key}) could not be found.",
        code: 'GTN:007'
      )
    end
  end
end
check_bad_heading_order(contents) click to toggle source
# File bin/lint.rb, line 741
def self.check_bad_heading_order(contents)
  depth = 1
  headings = find_matching_texts(contents, /^(?<level>#+)\s?(?<title>.*)/)
             .map do |idx, text, selected|
    new_depth = selected[:level].length
    depth_change = new_depth - depth
    depth = new_depth
    [idx, text, selected, depth_change, new_depth]
  end

  all_headings = headings.map do |_idx, _text, selected, _depth_change, _new_depth|
    "#{selected[:level]} #{selected[:title]}"
  end

  headings.select do |_idx, _text, _selected, depth_change, _new_depth|
    depth_change > 1
  end.map do |idx, _text, selected, depth_change, new_depth|
    ReviewDogEmitter.error(
      path: @path,
      idx: idx,
      match_start: selected.begin(1),
      match_end: selected.end(1) + 1,
      replacement: '#' * (new_depth - depth_change + 1),
      message: "You have skipped a heading level, please correct this.\n<details>" \
               "<summary>Listing of Heading Levels</summary>\n\n```\n#{all_headings.join("\n")}\n```\n</details>",
      code: 'GTN:028'
    )
  end
end
check_bad_icon(contents) click to toggle source
# File bin/lint.rb, line 347
def self.check_bad_icon(contents)
  find_matching_texts(contents, /{%\s*icon\s+([^%]*)\s*%}/i)
    .map do |idx, _text, selected|
    icon_key = selected[1].strip.split[0]
    if jekyll_config['icon-tag'][icon_key].nil?
      ReviewDogEmitter.error(
        path: @path,
        idx: idx,
        match_start: selected.begin(0),
        match_end: selected.end(0),
        replacement: nil,
        message: "The icon (#{icon_key}) could not be found, please add it to _config.yml.",
        code: 'GTN:033'
      )
    end
  end
end
check_bad_tag(contents) click to toggle source
# File bin/lint.rb, line 691
def self.check_bad_tag(contents)
  find_matching_texts(contents, /{%\s*(?<tag>[a-z]+)/)
    .reject { |_idx, _text, selected| @KNOWN_TAGS.include? selected[:tag] }
    .map do |idx, _text, selected|
    ReviewDogEmitter.warning(
      path: @path,
      idx: idx,
      match_start: selected.begin(1),
      match_end: selected.end(1) + 1,
      replacement: nil,
      message: "We're not sure this tag is correct (#{selected[:tag]}), it isn't one of the known tags.",
      code: 'GTN:021'
    )
  end
end
check_bolded_heading(contents) click to toggle source
# File bin/lint.rb, line 771
def self.check_bolded_heading(contents)
  find_matching_texts(contents, /^#+ (?<title>\*\*.*\*\*)$/)
    .map do |idx, _text, selected|
    ReviewDogEmitter.error(
      path: @path,
      idx: idx,
      match_start: selected.begin(1),
      match_end: selected.end(1) + 1,
      replacement: selected[:title][2..-3],
      message: 'Please do not bold headings, it is unncessary ' \
               'and will potentially cause screen readers to shout them.',
      code: 'GTN:029'
    )
  end
end
check_dois(contents) click to toggle source
# File bin/lint.rb, line 193
def self.check_dois(contents)
  find_matching_texts(contents, %r{(\[[^\]]*\]\(https?://doi.org/[^)]*\))})
    .reject { |_idx, _text, selected| selected[0].match(%r{10.5281/zenodo}) } # Ignoring zenodo
    .map do |idx, _text, selected|
    ReviewDogEmitter.warning(
      path: @path,
      idx: idx,
      match_start: selected.begin(0),
      match_end: selected.end(0) + 2,
      replacement: '{% cite ... %}',
      message: 'This looks like a DOI which could be better served by using the built-in Citations mechanism. ' \
               'You can use https://doi2bib.org to convert your DOI into a .bib formatted entry, ' \
               'and add to your tutorial.md',
      code: 'GTN:004'
    )
  end
end
check_looks_like_heading(contents) click to toggle source
# File bin/lint.rb, line 650
def self.check_looks_like_heading(contents)
  # TODO: we should remove this someday, but, we need to have a good solution
  # and we're still a ways from that.
  #
  # There's no clear way to say "this subsection of the content has its own hierarchy"
  return if @path.match(/faq/)

  find_matching_texts(contents, /^\*\*(.*)\*\*$/)
    .map do |idx, _text, selected|
    ReviewDogEmitter.warning(
      path: @path,
      idx: idx,
      match_start: selected.begin(1),
      match_end: selected.end(1) + 1,
      replacement: "### #{selected[1]}",
      message: "This looks like a heading, but isn't. Please use proper semantic headings where possible. " \
               'You should check the heading level of this suggestion, rather than accepting the change as-is.',
      code: 'GTN:020'
    )
  end
end
check_pmids(contents) click to toggle source
# File bin/lint.rb, line 211
def self.check_pmids(contents)
  # https://www.ncbi.nlm.nih.gov/pubmed/24678044
  find_matching_texts(contents,
                      %r{(\[[^\]]*\]\(https?://www.ncbi.nlm.nih.gov/pubmed//[0-9]*\))}).map do |idx, _text, selected|
    ReviewDogEmitter.warning(
      path: @path,
      idx: idx,
      match_start: selected.begin(0),
      match_end: selected.end(0) + 2,
      replacement: '{% cite ... %}',
      message: 'This looks like a PMID which could be better served by using the built-in Citations mechanism. ' \
               'You can use https://doi2bib.org to convert your PMID/PMCID into a .bib formatted entry, ' \
               'and add to your tutorial.md',
      code: 'GTN:004'
    )
  end
end
check_useless_box_prefix(contents) click to toggle source
# File bin/lint.rb, line 722
def self.check_useless_box_prefix(contents)
  find_matching_texts(contents, /<(?<tag>[a-z_-]+)-title>(?<fw>[a-zA-Z_-]+:?\s*)/)
    .select do |_idx, _text, selected|
    @BOX_CLASSES.include?(selected[:tag]) and selected[:tag] == selected[:fw].gsub(/:\s*$/, '').downcase
  end
    .map do |idx, _text, selected|
    ReviewDogEmitter.warning(
      path: @path,
      idx: idx,
      match_start: selected.begin(2),
      match_end: selected.end(2) + 1,
      replacement: '',
      message: "It is no longer necessary to prefix your #{selected[:tag]} box titles with " \
               "#{selected[:tag].capitalize}, this is done automatically.",
      code: 'GTN:022'
    )
  end
end
citation_library() click to toggle source
# File bin/lint.rb, line 302
def self.citation_library
  if @CITATION_LIBRARY.nil?
    lib = BibTeX::Bibliography.new
    (enumerate_type(/bib$/) + enumerate_type(/bib$/, root_dir: 'faqs')).each do |path|
      b = BibTeX.open(path)
      b.each do |x|
        # Record the bib path.
        x._path = path
        lib << x
      end
    end
    @CITATION_LIBRARY = lib
  end

  @CITATION_LIBRARY
end
code_limits(codes) click to toggle source
# File bin/lint.rb, line 1136
def self.code_limits(codes)
  @LIMIT_EMITTED_CODES = codes
end
cyoa_branches(contents) click to toggle source
# File bin/lint.rb, line 818
def self.cyoa_branches(contents)
  joined_contents = contents.join("\n")
  cyoa_branches = joined_contents.scan(/_includes\/cyoa-choices[^%]*%}/m)
    .map{|cyoa_line| 
      cyoa_line.gsub(/\n/, ' ') # Remove newlines, want it all one one line.
        .gsub(/\s+/, ' ') # Collapse multiple whitespace for simplicity
        .gsub(/_includes\/cyoa-choices.html/, '').gsub(/%}$/, '') # Strip start/end
        .strip
        .split('" ') # Split on the end of an option to get the individual option groups
        .map{|p| p.gsub(/="/, '=').split('=')}.to_h} # convert it into a convenient hash
  # NOTE: Errors on this line usually mean that folks have used ' instead of " in their CYOA.


  # cyoa_branches =
  # [{"option1"=>"Quick one tool method",
  #   "option2"=>"Convert to AnnData object compatible with Filter, Plot, Explore workflow",
  #   "default"=>"Quick one tool method",
  #   "text"=>"Choose below if you just want to convert your object quickly or see how it all happens behind the scenes!",
  #   "disambiguation"=>"seurat2anndata\""},
  
  # We use slugify_unsafe to convert it to a slug, now we should check:
  # 1. Is it unique in the file? No duplicate options?
  # 2. Is every branch used?

  # Uniqueness:
  options = cyoa_branches.map{|o| o.select{|k, v| k =~ /option/}.values}.flatten
  slugified = options.map{|o| [o, unsafe_slugify(o)]}
  slugified_grouped = slugified.group_by{|before, after| after}
    .map{|k, pairs| [k, pairs.map{|p| p[0]}]}.to_h
  
  errors = []
  if slugified_grouped.values.any?{|v| v.length > 1}
    dupes = slugified_grouped.select{|k, v| v.length > 1}
    msg = "We identified the following duplicate options in your CYOA: "
    msg += dupes.map do |slug, options|
      "Options #{options.join(', ')} became the key: #{slug}"
    end.join("; ")

    errors << ReviewDogEmitter.error(
      path: @path,
      idx: 0,
      match_start: 0,
      match_end: 1,
      replacement: nil,
      message: 'You have non-unique options in your Choose Your Own Adventure. Please ensure that each option is unique in its text. Unfortunately we do not currently support re-using the same option text across differently disambiguated CYOA branches, so, please inform us if this is a requirement for you.' + msg,
      code: 'GTN:041'
    )
  end

  # Missing default
  cyoa_branches.each do |branch|
    if branch['default'].nil?
      errors << ReviewDogEmitter.error(
        path: @path,
        idx: 0,
        match_start: 0,
        match_end: 1,
        replacement: nil,
        message: 'We recommend specifying a default for every branch',
        code: 'GTN:042'
      )
    end

    # Checking default/options correspondence.
    options = branch.select{|k, v| k =~ /option/}.values
    if branch.key?("default") && ! options.include?(branch['default'])
      if options.any?{|o| unsafe_slugify(o) == unsafe_slugify(branch['default'])}
        errors << ReviewDogEmitter.warning(
          path: @path,
          idx: 0,
          match_start: 0,
          match_end: 1,
          replacement: nil,
          message: "We did not see a corresponding option# for the default: «#{branch['default']}», but this could have been written before we automatically slugified the options. If you like, please consider making your default option match the option text exactly.",
          code: 'GTN:043'
        )
      else
        errors << ReviewDogEmitter.warning(
          path: @path,
          idx: 0,
          match_start: 0,
          match_end: 1,
          replacement: nil,
          message: "We did not see a corresponding option# for the default: «#{branch['default']}», please ensure the text matches one of the branches.",
          code: 'GTN:044'
        )
      end
    end
  end

  # Branch testing.
  cyoa_branches.each do |branch|
    options = branch
      .select{|k, v| k =~ /option/}
      .values

    # Check for matching lines in the file.
    options.each do |option|
      slug_option = unsafe_slugify(option)
      if !joined_contents.match(/#{slug_option}/)
        errors << ReviewDogEmitter.warning(
          path: @path,
          idx: 0,
          match_start: 0,
          match_end: 1,
          replacement: nil,
          message: "We did not see a branch for #{option} (#{slug_option}) in the file. Please consider ensuring that all options are used.",
          code: 'GTN:045'
        )
      end
    end
  end
  


  # find_matching_texts(contents, />\s*(\*\*\s*[Ss]tep)/) .map do |idx, _text, selected|
  #   ReviewDogEmitter.error(
  #     path: @path,
  #     idx: idx,
  #     match_start: selected.begin(1),
  #     match_end: selected.end(1) + 1,
  #     replacement: nil,
  #     message: 'This is a non-semantic list which is bad for accessibility and bad for screenreaders. ' \
  #              'It results in poorly structured HTML and as a result is not allowed.',
  #     code: 'GTN:035'
  #   )
  # end
  errors
end
emit_results(results) click to toggle source
# File bin/lint.rb, line 1202
def self.emit_results(results)
  return unless !results.nil? && results.length.positive?

  results.compact.flatten
    .select{|r| r.is_a? Hash }
    .each { |r| format_reviewdog_output(r) }
end
empty_alt_text(contents) click to toggle source
# File bin/lint.rb, line 580
def self.empty_alt_text(contents)
  find_matching_texts(contents, /!\[\]\(/i)
    .map do |idx, _text, selected|
    path = selected[1].to_s.strip
    if !File.exist?(path.gsub(%r{^/}, ''))
      ReviewDogEmitter.error(
        path: @path,
        idx: idx,
        match_start: selected.begin(0),
        match_end: selected.end(0),
        replacement: nil,
        message: 'The alt text for this image seems to be empty',
        code: 'GTN:034'
      )
    end
  end
end
enable_auto_fix() click to toggle source
# File bin/lint.rb, line 1141
def self.enable_auto_fix
  @AUTO_APPLY_FIXES = true
end
enumerate_all() click to toggle source
# File bin/lint.rb, line 1382
def self.enumerate_all
  enumerate_type(/.*/)
end
enumerate_lintable() click to toggle source
# File bin/lint.rb, line 1376
def self.enumerate_lintable
  enumerate_type(/bib$/) + enumerate_type(/md$/) + enumerate_type(/md$/,
                                                                  root_dir: 'faqs') + enumerate_type(/md$/,
                                                                                                     root_dir: 'news')
end
enumerate_type(filter, root_dir: 'topics') click to toggle source
# File bin/lint.rb, line 1346
def self.enumerate_type(filter, root_dir: 'topics')
  paths = []
  Find.find("./#{root_dir}") do |path|
    if FileTest.directory?(path)
      next unless File.basename(path).start_with?('.')

      Find.prune       # Don't look any further into this directory.

    elsif path.match(filter)
      paths.push(path)
    end
  end
  paths
end
filter_results(results, ignores) click to toggle source
# File bin/lint.rb, line 1214
def self.filter_results(results, ignores)
  if !results.nil?
    # Remove any empty lists
    results = results.select { |x| !x.nil? && x.length.positive? }.flatten
    # Before ignoring anything matching GTN:IGNORE:###
    return results if ignores.nil? or ignores.empty?

    results = results.select { |x| ignores.index(x['code']['value']).nil? } if results.length.positive?
    return results
  end
  nil
end
find_matching_texts(contents, query) click to toggle source
# File bin/lint.rb, line 115
def self.find_matching_texts(contents, query)
  contents.map.with_index do |text, idx|
    [idx, text, text.match(query)]
  end.select { |_idx, _text, selected| selected }
end
fix_bib(contents, bib) click to toggle source
# File bin/lint.rb, line 1086
def self.fix_bib(contents, bib)
  bad_keys = bib_missing_mandatory_fields(bib)
  results = []
  bad_keys.each do |key, reason|
    results += find_matching_texts(contents, /^\s*@.*{#{key},/)
               .map do |idx, text, _selected|
      ReviewDogEmitter.error(
        path: @path,
        idx: idx,
        match_start: 0,
        match_end: text.length,
        replacement: nil,
        message: reason,
        code: 'GTN:012'
      )
    end
  end

  # 13:  doi = {https://doi.org/10.1016/j.cmpbup.2021.100007},
  results += find_matching_texts(contents, %r{doi\s*=\s*\{(https?://doi.org/)})
             .map do |idx, _text, selected|
    ReviewDogEmitter.warning(
      path: @path,
      idx: idx,
      match_start: selected.begin(1),
      match_end: selected.end(1) + 1,
      replacement: '',
      message: 'Unnecessary use of URL in DOI-only field, please just use the doi component itself',
      code: 'GTN:031'
    )
  end
  results
end
fix_file(path) click to toggle source
# File bin/lint.rb, line 1227
def self.fix_file(path)
  @path = path

  if path.match(/\s/)
    emit_results([ReviewDogEmitter.file_error(path: path,
                                              message: 'There are spaces in this filename, that is forbidden.',
                                              code: 'GTN:014')])
  end

  if path.match(/\?/)
    emit_results([ReviewDogEmitter.file_error(path: path,
                                              message: 'There ?s in this filename, that is forbidden.',
                                              code: 'GTN:014')])
  end

  case path
  when /md$/
    handle = File.open(path, 'r')
    contents = handle.read.split("\n")
    ignores = should_ignore(contents)
    results = fix_md(contents)

    results = filter_results(results, ignores)
    emit_results(results)
  when /.bib$/
    handle = File.open(path, 'r')
    contents = handle.read.split("\n")

    bib = BibTeX.open(path)
    results = fix_bib(contents, bib)

    results = filter_results(results, ignores)
    emit_results(results)
  when /.ga$/
    handle = File.open(path, 'r')
    begin
      contents = handle.read
      data = JSON.parse(contents)
    rescue StandardError => e
      warn "Error parsing #{path}: #{e}"
      emit_results([ReviewDogEmitter.file_error(path: path, message: 'Unparseable JSON in this workflow file.',
                                                code: 'GTN:019')])
    end

    results = []
    # Check if there's a missing workflow test
    folder = File.dirname(path)
    basename = File.basename(path).gsub(/.ga$/, '')
    possible_tests = Dir.glob("#{folder}/#{Regexp.escape(basename)}*ym*")
    possible_tests = possible_tests.grep(/#{Regexp.escape(basename)}[_-]tests?.ya?ml/)

    contains_interactive_tool = contents.match(/interactive_tool_/)

    if possible_tests.empty?
      if !contains_interactive_tool
        results += [
          ReviewDogEmitter.file_error(path: path,
                                      message: 'This workflow is missing a test, which is now mandatory. Please ' \
                                               'see [the FAQ on how to add tests to your workflows](' \
                                               'https://training.galaxyproject.org/training-material/faqs/' \
                                               'gtn/gtn_workflow_testing.html).',
                                      code: 'GTN:027')
        ]
      end
    else
      # Load tests and run some quick checks:
      possible_tests.each do |test_file|
        if !test_file.match(/-tests.yml/)
          results += [
            ReviewDogEmitter.file_error(path: path,
                                        message: 'Please use the extension -tests.yml ' \
                                                 'for this test file.',
                                        code: 'GTN:032')
          ]
        end

        test = YAML.safe_load(File.open(test_file))
        test_plain = File.read(test_file)
        # check that for each test, the outputs is non-empty
        test.each do |test_job|
          if (test_job['outputs'].nil? || test_job['outputs'].empty?) && !test_plain.match(/GTN_RUN_SKIP_REASON/)
            results += [
              ReviewDogEmitter.file_error(path: path,
                                          message: 'This workflow test does not test the contents of outputs, ' \
                                                   'which is now mandatory. Please see [the FAQ on how to add ' \
                                                   'tests to your workflows](' \
                                                   'https://training.galaxyproject.org/training-material/faqs/' \
                                                   'gtn/gtn_workflow_testing.html).',
                                          code: 'GTN:030')
            ]
          end
        end
      end

    end

    # Check if they use TS tools, we do this here because it's easier to look at the plain text.
    contents.split("\n").each.with_index do |text, linenumber|
      if text.match(/testtoolshed/)
        results += [
          ReviewDogEmitter.error(
            path: @path,
            idx: linenumber,
            match_start: 0,
            match_end: text.length,
            replacement: nil,
            message: 'This step uses a tool from the testtoolshed. These are not permitted in GTN tutorials.',
            code: 'GTN:017'
          )
        ]
      end
    end
    results += fix_ga_wf(data)

    results = filter_results(results, ignores)
    emit_results(results)
  end
end
fix_ga_wf(contents) click to toggle source
# File bin/lint.rb, line 1026
def self.fix_ga_wf(contents)
  results = []
  if !contents.key?('tags') or contents['tags'].empty?
    path_parts = @path.split('/')
    topic = path_parts[path_parts.index('topics') + 1]

    results.push(ReviewDogEmitter.file_error(
                   path: @path, message: "This workflow is missing required tags. Please add `\"tags\": [\"#{topic}\"]`",
                   code: 'GTN:015'
                 ))
  end

  if !contents.key?('annotation')
    results.push(ReviewDogEmitter.file_error(
                   path: @path,
                   message: 'This workflow is missing an annotation. Please add `"annotation": "title of tutorial"`',
                   code: 'GTN:016'
                 ))
  end

  if !contents.key?('license')
    results.push(ReviewDogEmitter.file_error(
                   path: @path,
                   message: 'This workflow is missing a license. Please select a valid OSI license. ' \
                            'You can correct this in the Galaxy workflow editor.',
                   code: 'GTN:026'
                 ))
  end

  if contents.key?('creator')
    contents['creator']
      .select { |c| c['class'] == 'Person' }
      .each do |p|
        if !p.key?('identifier') || (p['identifier'] == '')
          results.push(ReviewDogEmitter.file_error(
                         path: @path,
                         message: 'This workflow has a creator but is missing an identifier for them. ' \
                                  'Please ensure all creators have valid ORCIDs.',
                         code: 'GTN:025'
                       ))
        end

        if !p.key?('name') || (p['name'] == '')
          results.push(ReviewDogEmitter.file_error(
                         path: @path, message: 'This workflow has a creator but is a name, please add it.',
                         code: 'GTN:025'
                       ))
        end
      end
  else
    results.push(ReviewDogEmitter.file_error(
                   path: @path,
                   message: 'This workflow is missing a Creator. Please edit this workflow in ' \
                            'Galaxy to add the correct creator entities',
                   code: 'GTN:024'
                 ))
  end
  results
end
fix_md(contents) click to toggle source
# File bin/lint.rb, line 964
def self.fix_md(contents)
  [
    *fix_notoc(contents),
    *youtube_bad(contents),
    *link_gtn_slides_external(contents),
    *link_gtn_tutorial_external(contents),
    *check_dois(contents),
    *check_pmids(contents),
    *check_bad_link_text(contents),
    *incorrect_calls(contents),
    *check_bad_cite(contents),
    *non_existent_snippet(contents),
    *bad_tool_links(contents),
    *check_tool_link(contents),
    *new_more_accessible_boxes(contents),
    *new_more_accessible_boxes_agenda(contents),
    *no_target_blank(contents),
    *check_bad_link(contents),
    *check_bad_icon(contents),
    *check_looks_like_heading(contents),
    *check_bad_tag(contents),
    *check_useless_box_prefix(contents),
    *check_bad_heading_order(contents),
    *check_bolded_heading(contents),
    *snippets_too_close_together(contents),
    *bad_zenodo_links(contents),
    *zenodo_api(contents),
    *empty_alt_text(contents),
    *check_bad_trs_link(contents),
    *nonsemantic_list(contents),
    *cyoa_branches(contents),
    *useless_intro(contents)
  ]
end
fix_notoc(contents) click to toggle source
# File bin/lint.rb, line 121
def self.fix_notoc(contents)
  find_matching_texts(contents, /{:\s*.no_toc\s*}/)
    .map do |idx, text, _selected|
    ReviewDogEmitter.delete_text(
      path: @path,
      idx: idx,
      text: text,
      message: 'Setting no_toc is discouraged, these headings provide useful places for readers to jump to.',
      code: 'GTN:001',
      full_line: text
    )
  end
end
format_reviewdog_output(message) click to toggle source
# File bin/lint.rb, line 1145
def self.format_reviewdog_output(message)
  return if message.nil? || message.empty?
  return if !@LIMIT_EMITTED_CODES.nil? && !@LIMIT_EMITTED_CODES.include?(message['code']['value'])


  if !message.nil? && (message != []) && message.is_a?(Hash)
    path = message['location']['path']
    if @SHORT_PATH && path.include?(GTN_HOME + '/')
      path = path.gsub(GTN_HOME + '/', '')
    end
    if @PLAIN_OUTPUT # $stdout.tty? or
      parts = [
        path,
        message['location']['range']['start']['line'],
        message['location']['range']['start']['column'],
        message['location']['range']['end']['line'],
        message['location']['range']['end']['column'],
        "#{message['code']['value'].gsub(/:/, '')} #{message['message'].split("\n")[0]}"
      ]
      puts parts.join(':')
    else
      puts JSON.generate(message)
    end
  end

  return unless @AUTO_APPLY_FIXES && message['suggestions'].length.positive?

  start_line = message['location']['range']['start']['line']
  start_coln = message['location']['range']['start']['column']
  end_line = message['location']['range']['end']['line']
  end_coln = message['location']['range']['end']['column']

  if start_line == end_line
    # We only really support single-line changes. This will probs fuck up
    lines = File.read(message['location']['path']).split("\n")
    original = lines[start_line - 1].dup

    repl = message['suggestions'][0]['text']

    # puts "orig #{original}"
    # puts "before #{original[0..start_coln - 2]}"
    # puts "selected '#{original[start_coln-1..end_coln-2]}'"
    # puts "after #{original[end_coln-2..-1]}"
    # puts "replace: #{repl}"

    # puts "#{original[0..start_coln - 2]} + #{repl} + #{original[end_coln-1..-1]}"
    fixed = original[0..start_coln - 2] + repl + original[end_coln - 1..]
    warn "DIFF\n-#{original}\n+#{fixed}"
    lines[start_line - 1] = fixed

    # Save our changes
    File.write(message['location']['path'], (lines + ['']).join("\n"))
  else
    warn 'Cannot apply this suggestion sorry'
  end
end
incorrect_calls(contents) click to toggle source
# File bin/lint.rb, line 246
def self.incorrect_calls(contents)
  a = find_matching_texts(contents, /([^{]|^)(%\s*[^%]*%})/i)
      .map do |idx, _text, selected|
    ReviewDogEmitter.error(
      path: @path,
      idx: idx,
      match_start: selected.begin(2),
      match_end: selected.end(2) + 1,
      replacement: "{#{selected[2]}",
      message: 'It looks like you might be missing the opening { of a jekyll function',
      code: 'GTN:006'
    )
  end
  b = find_matching_texts(contents, /{([^%]\s*[^%]* %})/i)
      .map do |idx, _text, selected|
    ReviewDogEmitter.error(
      path: @path,
      idx: idx,
      match_start: selected.begin(1),
      match_end: selected.end(1) + 1,
      replacement: "%#{selected[1]}",
      message: 'It looks like you might be missing the opening % of a jekyll function',
      code: 'GTN:006'
    )
  end

  c = find_matching_texts(contents, /({%\s*[^%]*%)([^}]|$)/i)
      .map do |idx, _text, selected|
    ReviewDogEmitter.error(
      path: @path,
      idx: idx,
      match_start: selected.begin(1),
      match_end: selected.end(1) + 2,
      replacement: "#{selected[1]}}#{selected[2]}",
      message: 'It looks like you might be missing the closing } of a jekyll function',
      code: 'GTN:006'
    )
  end

  d = find_matching_texts(contents, /({%\s*[^}]*[^%])}/i)
      .map do |idx, _text, selected|
    ReviewDogEmitter.error(
      path: @path,
      idx: idx,
      match_start: selected.begin(1),
      match_end: selected.end(1) + 1,
      replacement: "#{selected[1]}%",
      message: 'It looks like you might be missing the closing % of a jekyll function',
      code: 'GTN:006'
    )
  end
  a + b + c + d
end
jekyll_config() click to toggle source
# File bin/lint.rb, line 321
def self.jekyll_config
  if @JEKYLL_CONFIG.nil?
    # Load
    @JEKYLL_CONFIG = YAML.load_file('_config.yml')
  end
  @JEKYLL_CONFIG
end
new_more_accessible_boxes(contents) click to toggle source
# File bin/lint.rb, line 531
def self.new_more_accessible_boxes(contents)
  #  \#\#\#
  find_matching_texts(contents, /> (### {%\s*icon ([^%]*)\s*%}[^:]*:?(.*))/)
    .map do |idx, _text, selected|
    key = selected[2].strip.gsub(/_/, '-')
    ReviewDogEmitter.error(
      path: @path,
      idx: idx,
      match_start: selected.begin(1),
      match_end: selected.end(1) + 1,
      replacement: "<#{key}-title>#{selected[3].strip}</#{key}-title>",
      message: 'We have developed a new syntax for box titles, please consider using this instead.',
      code: 'GTN:010'
    )
  end
end
new_more_accessible_boxes_agenda(contents) click to toggle source
# File bin/lint.rb, line 548
def self.new_more_accessible_boxes_agenda(contents)
  #  \#\#\#
  find_matching_texts(contents, /> (###\s+Agenda\s*)/)
    .map do |idx, _text, selected|
    ReviewDogEmitter.error(
      path: @path,
      idx: idx,
      match_start: selected.begin(1),
      match_end: selected.end(1) + 1,
      replacement: '<agenda-title></agenda-title>',
      message: 'We have developed a new syntax for box titles, please consider using this instead.',
      code: 'GTN:010'
    )
  end
end
no_target_blank(contents) click to toggle source
# File bin/lint.rb, line 564
def self.no_target_blank(contents)
  find_matching_texts(contents, /target=("_blank"|'_blank')/)
    .map do |idx, _text, selected|
    ReviewDogEmitter.warning(
      path: @path,
      idx: idx,
      match_start: selected.begin(0),
      match_end: selected.end(0),
      replacement: nil,
      message: 'Please do not use `target="_blank"`, [it is bad for accessibility.]' \
               '(https://www.a11yproject.com/checklist/#identify-links-that-open-in-a-new-tab-or-window)',
      code: 'GTN:011'
    )
  end
end
non_existent_snippet(contents) click to toggle source
# File bin/lint.rb, line 365
def self.non_existent_snippet(contents)
  find_matching_texts(contents, /{%\s*snippet\s+([^ ]*)/i)
    .reject do |_idx, _text, selected|
    File.exist?(selected[1])
  end
    .map do |idx, _text, selected|
    ReviewDogEmitter.error(
      path: @path,
      idx: idx,
      match_start: selected.begin(0),
      match_end: selected.end(0),
      replacement: nil,
      message: "This snippet (`#{selected[1]}`) does not seem to exist",
      code: 'GTN:008'
    )
  end
end
nonsemantic_list(contents) click to toggle source
# File bin/lint.rb, line 802
def self.nonsemantic_list(contents)
  find_matching_texts(contents, />\s*(\*\*\s*[Ss]tep)/)
    .map do |idx, _text, selected|
    ReviewDogEmitter.error(
      path: @path,
      idx: idx,
      match_start: selected.begin(1),
      match_end: selected.end(1) + 1,
      replacement: nil,
      message: 'This is a non-semantic list which is bad for accessibility and bad for screenreaders. ' \
               'It results in poorly structured HTML and as a result is not allowed.',
      code: 'GTN:035'
    )
  end
end
run_linter_global() click to toggle source
# File bin/lint.rb, line 1386
def self.run_linter_global
  enumerate_type(/:/).each do |path|
    format_reviewdog_output(
      ReviewDogEmitter.file_error(path: path,
                                  message: 'There are colons in this filename, that is forbidden.', code: 'GTN:014')
    )
  end

  enumerate_symlinks.each do |path|
    if !File.exist?(Pathname.new(path).realpath)
      format_reviewdog_output(
        ReviewDogEmitter.file_error(path: path, message: 'This is a BAD symlink', code: 'GTN:013')
      )
    end
  rescue StandardError
    format_reviewdog_output(
      ReviewDogEmitter.file_error(path: path, message: 'This is a BAD symlink', code: 'GTN:013')
    )
  end
  enumerate_type(/data[_-]library.ya?ml/).each do |path|
    if path.split('/')[-1] != 'data-library.yaml'
      format_reviewdog_output(
        ReviewDogEmitter.file_error(path: path,
                                    message: 'This file must be named data-library.yaml. Please rename it.',
                                    code: 'GTN:023')
      )
    end
  end
  enumerate_type(/\.ga$/).each do |path|
    fix_file(path)
  end
  enumerate_lintable.each do |path|
    fix_file(path)
  end
end
set_plain_output() click to toggle source
# File bin/lint.rb, line 1122
def self.set_plain_output
  @PLAIN_OUTPUT = true
end
set_rdjson_output() click to toggle source
# File bin/lint.rb, line 1126
def self.set_rdjson_output
  @PLAIN_OUTPUT = false
end
set_short_path() click to toggle source
# File bin/lint.rb, line 1131
def self.set_short_path
  @SHORT_PATH = true
end
should_ignore(contents) click to toggle source
# File bin/lint.rb, line 1210
def self.should_ignore(contents)
  contents.select { |x| x.match(/GTN:IGNORE:(\d\d\d)/) }.map { |x| "GTN:#{x.match(/GTN:IGNORE:(\d\d\d)/)[1]}" }.uniq
end
snippets_too_close_together(contents) click to toggle source
# File bin/lint.rb, line 415
def self.snippets_too_close_together(contents)
  prev_line = -2
  res = []
  find_matching_texts(contents, /^[> ]*{% snippet/)
    .each do |idx, _text, selected|
    if idx == prev_line + 1
      res.push(ReviewDogEmitter.error(
                 path: @path,
                 idx: idx,
                 match_start: selected.begin(0),
                 match_end: selected.end(0) + 1,
                 replacement: nil,
                 message: 'Snippets too close together',
                 code: 'GTN:032'
               ))
    end
    prev_line = idx
  end
  res
end
useless_intro(contents) click to toggle source
# File bin/lint.rb, line 948
def self.useless_intro(contents)
  joined_contents = contents.join("\n")
  joined_contents.scan(/\n---\n+# Introduction/m)
    .map do |line|
    ReviewDogEmitter.error(
      path: @path,
      idx: 0,
      match_start: 0,
      match_end: 0,
      replacement: '',
      message: 'Please do not include an # Introduction section, it is unnecessary here, just start directly into your text. The first paragraph that is seen by our infrastructure will automatically be shown in a few places as an abstract.',
      code: 'GTN:046'
    )
  end
end
youtube_bad(contents) click to toggle source

GTN:002 youtube discouraged

# File bin/lint.rb, line 136
def self.youtube_bad(contents)
  find_matching_texts(contents, %r{<iframe.*youtu.?be.*</iframe>})
    .map do |idx, _text, selected|
    ReviewDogEmitter.warning(
      path: @path,
      idx: idx,
      match_start: selected.begin(0),
      match_end: selected.end(0) + 1,
      replacement: '',
      message: 'Instead of embedding IFrames to YouTube contents, consider adding this video to the ' \
               'GTN tutorial "recordings" metadata where it will ' \
               'be more visible for others.',
      code: 'GTN:002'
    )
  end
end
zenodo_api(contents) click to toggle source
# File bin/lint.rb, line 787
def self.zenodo_api(contents)
  find_matching_texts(contents, %r{(zenodo\.org/api/files/)})
    .map do |idx, _text, selected|
    ReviewDogEmitter.error(
      path: @path,
      idx: idx,
      match_start: selected.begin(1),
      match_end: selected.end(1) + 1,
      replacement: nil,
      message: 'The Zenodo.org/api URLs are not stable, you must use a URL of the format zenodo.org/record/...',
      code: 'GTN:032'
    )
  end
end