// Copyright Epic Games, Inc. All Rights Reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Xml;
using EpicGames.Core;
using Microsoft.Extensions.Logging;
using UnrealBuildBase;
namespace AutomationTool.Tasks
{
///
/// Parameters for a CheckMarkdown task
///
public class CheckMarkdownTaskParameters
{
///
/// Optional filter to be applied to the list of input files.
///
[TaskParameter(Optional = true, ValidationType = TaskParameterValidationType.FileSpec)]
public string Files { get; set; }
}
///
/// Checks that all markdown links between the given files are valid.
///
[TaskElement("CheckMarkdown", typeof(CheckMarkdownTaskParameters))]
public class CheckMarkdownTask : BgTaskImpl
{
readonly CheckMarkdownTaskParameters _parameters;
///
/// Constructor
///
/// Parameters for this task
public CheckMarkdownTask(CheckMarkdownTaskParameters parameters)
{
_parameters = parameters;
}
///
public override async Task ExecuteAsync(JobContext job, HashSet buildProducts, Dictionary> tagNameToFileSet)
{
HashSet files = ResolveFilespec(Unreal.RootDirectory, _parameters.Files, tagNameToFileSet);
HashSet markdownFiles = files.Where(x => x.HasExtension(".md")).ToHashSet();
Logger.LogInformation("Checking {NumFiles} markdown files...", markdownFiles.Count);
// Build a set of valid link targets
HashSet validLinks = new HashSet(StringComparer.Ordinal);
foreach (FileReference file in files)
{
validLinks.Add(file.FullName);
}
// Find the anchors in any markdown files
foreach (FileReference file in markdownFiles)
{
string[] lines = await FileReference.ReadAllLinesAsync(file);
foreach (string line in lines)
{
Match match = Regex.Match(line, @"^#+\s+(.*)");
if (match.Success)
{
string anchor = match.Groups[1].Value.ToLowerInvariant().Trim();
anchor = Regex.Replace(anchor, @"\s+", "-");
anchor = Regex.Replace(anchor, @"[^a-z0-9-]", "");
validLinks.Add($"{file.FullName}#{anchor}");
}
}
}
// Add anchors for any images
foreach (FileReference file in files)
{
if (file.HasExtension(".png") || file.HasExtension(".jpg") || file.HasExtension(".jpeg") || file.HasExtension(".gif"))
{
validLinks.Add($"{file.FullName}#gh-dark-mode-only");
validLinks.Add($"{file.FullName}#gh-light-mode-only");
}
}
// Check the links
foreach (FileReference file in markdownFiles)
{
string[] lines = await FileReference.ReadAllLinesAsync(file);
for (int lineIdx = 0; lineIdx < lines.Length; lineIdx++)
{
string line = lines[lineIdx];
foreach (Match match in Regex.Matches(line, @"\[([^\]]+)\]\(([^\)]*)\)"))
{
string link = match.Groups[2].Value;
if (!Regex.IsMatch(link, "^(?:[a-z]+:/)?/"))
{
if (!link.Contains('\\', StringComparison.Ordinal))
{
int hashIdx = link.IndexOf('#', StringComparison.Ordinal);
if (hashIdx == -1)
{
link = FileReference.Combine(file.Directory, link).FullName;
}
else if (hashIdx == 0)
{
link = $"{file}{link[hashIdx..]}";
}
else
{
link = $"{FileReference.Combine(file.Directory, link[0..hashIdx])}{link[hashIdx..]}";
}
}
bool validLink;
try
{
validLink = validLinks.Contains(link);
}
catch
{
validLink = false;
}
if (!validLink)
{
Logger.LogWarning("{File}({Line}): Invalid link '{Link}'", file, lineIdx + 1, match.Groups[0].Value);
}
}
}
}
}
}
///
public override void Write(XmlWriter writer)
=> Write(writer, _parameters);
///
public override IEnumerable FindConsumedTagNames()
=> FindTagNamesFromFilespec(_parameters.Files);
///
public override IEnumerable FindProducedTagNames()
=> Array.Empty();
}
}