🔖

Graphviz, plantuml, node.js を使ってショボいコードナビゲーションを作ってみた

2024/04/28に公開

[ちょっとしたツール] Graphviz, plantuml, node.js を使ってショボいコードナビゲーションを作ってみた

概要

タイトルの通り、Graphviz, plantuml から作成した svg とソースコードを突き合わせができるツールを作ってみました。


上図のように、Graphviz や plantuml で作成された svg をブラウザで表示して、ラベルをクリックすると特定のコードが表示されるようなものになります。

クリックに割り当てられる url に反応するサーバーは node.js を使って用意します。

必要なツール

以下のツールを準備します。

  • Graphviz
  • plantuml
  • node.js

基本的な骨格

ファイルサーバー

node.js を使ってファイルサーバーを準備します。

今回は、express 等の拡張パッケージは使わず node.js のみで実装してみます。

では、定型コードから

//////////
// Copyright(C) 2024 K.ota All Rights Reserved.
//

//////////
// load library
const lib = {
    http: require('http'),
    path: require('path'),
    fs: require('fs')
};

:
// 一部省略
:

//////////
// send_file
function send_file(res, fn, extname) {
    if (! lib.fs.existsSync(fn)) {
        return error_reply(res, 404, fn + ': Not found');
    }
    let contents = lib.fs.readFileSync(fn, 'utf8');
    let mimetype = "text/plain";
    switch(extname) {
    case '.svg': mimetype = 'image/svg+xml'; break;
    case ".htm":
    case '.html': mimetype = 'text/html'; break;
    }
    res.writeHead(200, {
        'Content-Type': mimetype
    });
    res.end(contents);
}

//////////
// reply
function reply(req, res) {
    let url = new URL(config.host + req.url);
    if (url.pathname == '/') {
        send_file(res, 'index.html', '.html');
        error(res, 404, url.pathname + ": Not found. 0x0001");
    } else {
        let fn = url.pathname;
        let extname = lib.path.extname(fn);
        switch(extname) {
        case ".hpp":
        case ".h":
        case ".cpp":
        case ".c":
            send_code(req, res, "." + fn);
            break;

        default:
            send_file(res, "." + fn, extname);
            break;
        }
    }
}

//////////
// server
const server = lib.http.createServer((req, res) => {
    reply(req, res);
});
server.listen(config.port);

dot を使った svg の作成

dot から外部リンクを作るには以下のように記述します。

digraph chart {
    A -> B [label = "trace", href="http://localhost:3000/sample/sample.cpp" ];
}

dot から svg を生成するには

$ dot -Tsvg <infile> -o <outfile>

plantuml を使った svg の作成

plantuml から外部へのリンクを作るには以下のように記述します。

[[url(tips) label]]

例えば、以下のような記載を行います。

@startuml
sum -> trace : "[[http://localhost:3000/sample/sample.cpp Trace]]"
@enduml

上記の例は、sample/sample.cpp を表示するように指示することになります。

plantuml のコードから svg を生成するには

$ java -jar <plantuml.jar> -charset UTF-8 -graphvizdot <dot.exeへのパス> -svg <infile>

となります。

ナビゲーション

上記のやり方だとファイルしか表示されないので、まだナビゲーションとは呼べません。

コードをナビゲートするために、以下の機能を追加します。

  • コード上の行番号を指定して表示する

    特定の行を指定する機能は、html とブラウザの機能を使って実現したいと思います。このため、以下の2つの機能が必要になります。
    • URLに'#'を入れて特定の位置を表示するようにする
    • ソースコードにリンクタグを入れる
  • コード上の特定の行をハイライトする

    特定の行をハイライトするようにURLにパラメータを追加する。

これらを合わせると、

  • URL に行をハイライトする行と表示する行を指定する
  • サーバーから返却するコードに、リンクタグとハイライトをつけた html コードを返すようにする

という機能が必要になります。

URLにパラメータとリンクタグを設定するには、rfc3986に従い

scheme://authority/path?query#fragment

となるので、例えば、sample/sample.cpp の 45行目と47行目をハイライトして、40行目から表示したい場合は、

http://localhost:3000/sample/sample.cpp?L=45,47#L40

のような URL を解釈できるようにする必要があります。

ハイライトする行番号を指定するパラメータの解析

前述したように L=45,47 のように、","で複数指定できるように、また"-"を使って範囲指定できるようにしてみます。

//////////
// line manager
class LineManager {
    constructor(instruction) {
        this._list = [];
        if (instruction == null) {
            return;
        }

        if (instruction.indexOf(',') >= 0) {
            let items = instruction.split(',');
            for(let i=0; i<items.length; i++) {
                if (items[i].indexOf('-') >= 0) {
                    let minmax = items[i].split('-');
                    this._list.push({type: 2, min: minmax[0], max: minmax[1]});
                } else {
                    this._list.push({type: 1, value: items[i]});
                }
            }
        } else {
            this._list.push({type: 1, value: instruction});
        }
    }

    contains(lineNo) {
        for(let i=0; i<this._list.length; i++) {
            switch(this._list[i].type) {
            case 1:
                if (lineNo == this._list[i].value) {
                    return true;
                }
                break;

            case 2:
                if ((lineNo >= this._list[i].min) && (lineNo <= this._list[i].max)) {
                    return true;
                }
            }
        }
        return false;
    }
}

コンストラクタでパラメータを解析し、contains メソッドで行番号がパラメータに含まれているかどうかを判定するようにします。

ソースコードの排出

ソースコードを排出する際に、行ごとにリンクタグをつけるようにします。また、前述では省略しましたが、ソースコード中にある "<", ">"文字の置き換えや行コメントやブロックコメントについて色付けを行います。

//////////
// send_code
function send_code(req, res, fn) {
    if (! lib.fs.existsSync(fn)) {
        return error_reply(res, 404, fn + ': Not found.');
    }
    let content = lib.fs.readFileSync(fn, 'utf8');

    // formatting
    content= content.replaceAll('<', '&lt;');
    content = content.replaceAll('>', '&gt;');
    content = content.replaceAll(' ', '&nbsp;');
    content = content.replaceAll('\t', '&nbsp;&nbsp;&nbsp;&nbsp;');
    content = content.replaceAll(/\/\/(.*)\r?\n/g, '<span class=comment>//$1</span>\n');
    content = content.replaceAll(/\r?\n/g, '&nbsp;\n');

    let url = new URL(config.host + req.url)
    let lineMgr = new LineManager(url.searchParams.get('L'));
    let lines = content.split(/\r?\n/g);
    let body = '';
    let inComment = 0;
    for(let i=0; i<lines.length; i++) {
        let lineNo = i + 1;
        
        let lineClassName = "normal";
        if (lineMgr.contains(lineNo)) {
            lineClassName = "highlight";
        }

        let commentClassName = "normal";
        if (lines[i].indexOf('/*') >= 0) {
            inComment = 1; // comment:in
        }
        if (lines[i].indexOf('*/') >= 0) {
            inComment = 2; // comment:out
        }
        if (inComment > 0) {
            commentClassName = "comment";
            if (inComment == 2) {
                inComment = 0;
            }
        }

        let line = '<div class=' + lineClassName + '>'
            + '<a name=L' + lineNo + '>'
            + num2str(lineNo, 4)
            + '</a>'
            + ' | '
            + '<span class=' + commentClassName + '>'
            + lines[i]
            + '</span>'
            + '</div>'
            ;

        body += line;
    }

    body = config.template.replace("%%CODE%%", body);
    body = body.replace('%%FILENAME%%', fn);

    res.writeHead(200, {
        'Content-Type': 'text/html'
    });
    res.end(body);
}
body = config.template.replace("%%CODE%%", body);
body = body.replace('%%FILENAME%%', fn);

は、config.template に html のひな形を作っておき、そこに記述されている文字列 "%%FILENAME%%" と "%%CODE%%" を置き換えるようにしています。

最後に

このツールでは、サーバーはソースコードを html 化して送信していますが、syntaxhighlighter.js 等の highlighter と自作の javascript を組み込んだ html を使って、さらに改良することができるように思います。

Discussion