我突然意识到,自己的程序员生涯早就已经超过十年了,但却从来没有使用程序来为自己解决过问题。难道写程序只能用于打工吗?最近终于尝试通过写代码为自己解决一些实际问题,虽然只是一个非常非常小的开始,但效果令我极度满意。

问题

我有一本书籍的 markdown 源码,我现在需要将这本书中所有的代码导出成文件,并且按照章节命名。

尝试不写代码

我当然不只一次听过,拿着锤子,就会觉得所有的问题都是钉子等等这种嘲笑程序员总是喜欢通过写代码的方式来解决问题的现象。所以针对以上问题,我首先尝试的是,能不能不写代码完成以上任务。我想了很久,没有头绪,于是就去问了人工智能 GPT-4。
感觉人工智能就是一个超级厉害的程序员,我问了它这个问题,它还是建议我写一个脚本,并给出了一段 python 脚本。
image.png

选择编程语言

人工智能建议我用 python,我没有采纳。因为我对它不熟悉,并且工作中也用不到。我希望自己能够在解决这个问题时,还能够对我工作有所帮助。我最近工作中使用的 C#,也不熟悉,正好可以在业余时间解决自己的问题时,学习学习 C#,所以准备采用 C# 来完成这个任务。
其实我纠结了很久,才放弃了 nodejs。nodejs 我比较熟悉,但是这本书的内容非常多,A4 纸打印出来有 600多页,我的直觉是采用 nodejs,会花比较长的时间才能出结果,不够酷。当然,最重要的还是想学习一下 C#,后来的结果也是,快到让一个有经验的 nodejs 程序员非常不适应。

第一个测试用例

image.png
我先写了一个非常简单的测试用例,写好了自己期待的工作方式。对于一个简单的 markdown 文件,一共有两个代码段,分别出现在第一章和第二章的内容里。它能够提取出这两个代码段,并且能够为这两个代码段起出相应的文件名。
image.png
测试代码是这样的: csharp [Fact] public void Should_DistillCodeBlocks_SimpleCase() { // arrange var testDirectory = AppDomain.CurrentDomain.BaseDirectory; var fixturesDirectory = Path.Combine(testDirectory ?? throw new InvalidOperationException(), .., .., .., fixtures); const string inputFileName = simple.md; var fullInputFilePath = Path.Combine(fixturesDirectory, inputFileName); var input = File.ReadAllText(fullInputFilePath);

    // act
    var distiller = new CodeBlockDistiller();
    var actual = distiller.Distill(input, 1);

    // assert
    actual.Count.Should().Be(2);

    actual[0].Language.Should().Be(csharp);
    actual[0].Code.Should().Be(Console.WriteLine(Hello, World!);n);
    actual[0].GetFileName().Should().Be(第1章_第1段.cs);

    actual[1].Language.Should().Be();
    actual[1].Code.Should().Be(Hellon);
    actual[1].GetFileName().Should().Be(第2章_第1段.txt);
}

代码段的设计

首先,我为它设计了几个字段,分别是章、节、节内序号,以及程序代码的语言和代码内容本身。然后,又添加了几个方法,最终如下: csharp namespace MarkdownBook;

public class CodeBlock { public int Chapter { get; set; } public int Section { get; set; } public int Index { get; set; } public string? Language { get; init; } public string? Code { get; set; }

public string GetFileName()
{
    if (Section == 0)
    {
        return $第{Chapter}章_第{Index}段.{CodeFilenameExtensionHelper.GetExtensionByLanguage(Language)};
    }

    return $第{Chapter}章_第{Section}节_第{Index}段.{CodeFilenameExtensionHelper.GetExtensionByLanguage(Language)};
}

private string? GetFileContent()
{
    return Code;
}

public void Save(string path)
{
    File.WriteAllText(Path.Combine(path, GetFileName()), GetFileContent());
}

}

扫描文件的实现:按行处理

自己在很多年前写过一些语法解析器,都是逐字符读取的。又为 markdown 解析器程序写过一些插件,是基于 markdown 语法树的。这次的代码提取,应该采用什么方式呢?这个我没有动脑筋去仔细思考,直接看了人工智能给出的 python 脚本,发现它是按行处理的。我直接抄了一下,后来发现这个方法的效果非常好,并且有一点理解了为什么 git 也是对文本文件按行处理,真的非常适合。
当然,通过第一个测试后,又接着写了第二、第三个测试用例,并且完善了扫描文件的代码,最终是这样的: csharp using System.Text.RegularExpressions;

namespace MarkdownBook;

public class CodeBlockDistiller { private readonly Regex _chapterPattern = new Regex(@^s*###s+(.), RegexOptions.Multiline); private readonly Regex _sectionPattern = new Regex(@^s####s+(.*), RegexOptions.Multiline);

public List<CodeBlock> Distill(string input, int offset = 0)
{
    var codeBlocks = new List<CodeBlock>();
    var lines = input.Split(n);
    CodeBlock currentBlock = null;

    var codeBlockStarts = false;

    var chapter = 0;
    var section = 0;
    var index = 0;

    foreach (var line in lines)
    {
        if (_chapterPattern.Match(line).Success)
        {
            chapter++;
            section = 0;
            index = 0;
        }

        if (_sectionPattern.Match(line).Success)
        {
            section++;
            index = 0;
        }

        if (line.Trim().StartsWith())
        {
            if (codeBlockStarts == false)
            {
                codeBlockStarts = true;
                currentBlock = new CodeBlock
                {
                    Language = line.Trim()[3..].Trim().Split( ).First().Split({).First()
                };

                index++;

                currentBlock.Chapter = chapter;
                currentBlock.Section = section;
                currentBlock.Index = index;
            }
            else
            {
                codeBlockStarts = false;
                codeBlocks.Add(currentBlock);
                currentBlock = new CodeBlock();
            }
        }
        else if (codeBlockStarts)
        {
            currentBlock!.Code += line + n;
        }
    }

    return codeBlocks;
}

}

文件后缀名的映射

写了一个简单的程序语言和代码后缀名的映射器,代码如下: csharp namespace MarkdownBook;

public class CodeFilenameExtensionHelper { public static string GetExtensionByLanguage(string? language) { return language switch { csharp => cs, javascript => js, js => js, typescript => ts, plaintext => txt, shell => sh, powershell => ps1, xml => xml, json => json, yaml => yaml, html => html, css => css, sql => sql, dockerfile => dockerfile, => txt, mermaid => mermaid, plantuml => plantuml, java => java, txt => txt, bash => bash, tsx => tsx, jsx => jsx, vue => vue, properties => properties, yml => yml, diff => diff, ruby => rb, groovy => groovy, cmd => cmd, _ => throw new ArgumentOutOfRangeException(nameof(language), language, null) }; } }

问题的完美解决

最终,将超级大文件(书本 markdown 源码)放在测试文件夹,然后写了一个测试用例,对它进行读取,扫描,并且验证生成的代码文件数符合预期: csharp

[Fact]
public void Should_SaveFiles_ForLongContent()
{
    // arrange
    var testDirectory = AppDomain.CurrentDomain.BaseDirectory;
    var fixturesDirectory = Path.Combine(testDirectory ?? throw new InvalidOperationException(), .., .., ..,
        fixtures);
    const string inputFileName = long.md;
    var fullInputFilePath = Path.Combine(fixturesDirectory, inputFileName);
    var input = File.ReadAllText(fullInputFilePath);

    var distiller = new CodeBlockDistiller();
    var actual = distiller.Distill(input, 0);

    var outputDirectory = Path.Combine(fixturesDirectory, output);
    Directory.CreateDirectory(outputDirectory);
    var existingFiles = Directory.GetFiles(outputDirectory);
    foreach (var existingFile in existingFiles)
    {
        File.Delete(existingFile);
    }

    // act
    foreach (var codeBlock in actual)
    {
        codeBlock.Save(outputDirectory);
    }

    // assert
    var files = Directory.GetFiles(outputDirectory);
    files.Length.Should().Be(245);
}

}

运行了这个测试,仅 162 毫秒就运行完了,实在香:
image.png
然后到 output 目录下,人肉抽查了生成的文件,有点不敢相信结果竟然如此完美。
image.png