fast-pgn-parser 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/binding.gyp +43 -0
- package/package.json +28 -0
- package/scripts/benchmark-parse.mjs +57 -0
- package/scripts/fix-node-gyp-vs18.cjs +53 -0
- package/scripts/games.pgn +2080 -0
- package/scripts/install-libpgn.cjs +45 -0
- package/src/binding.cc +130 -0
- package/src/index.js +61 -0
- package/test/index.test.js +108 -0
- package/vendor/.gitkeep +0 -0
- package/vendor/README.md +17 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Ensures vendor/libpgn exists (clones from GitHub if missing).
|
|
4
|
+
* Run before node-gyp so the native addon can build and link libpgn.
|
|
5
|
+
*/
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { execSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const vendorLibpgn = path.join(__dirname, '..', 'vendor', 'libpgn');
|
|
11
|
+
const pgnC = path.join(vendorLibpgn, 'pgn.c');
|
|
12
|
+
|
|
13
|
+
if (fs.existsSync(pgnC)) {
|
|
14
|
+
console.log('vendor/libpgn already present');
|
|
15
|
+
runNodeGyp();
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const vendorDir = path.join(__dirname, '..', 'vendor');
|
|
20
|
+
if (!fs.existsSync(vendorDir)) {
|
|
21
|
+
fs.mkdirSync(vendorDir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
console.log('Cloning libpgn into vendor/libpgn...');
|
|
25
|
+
execSync('git clone --depth 1 https://github.com/fwttnnn/libpgn.git libpgn', {
|
|
26
|
+
cwd: vendorDir,
|
|
27
|
+
stdio: 'inherit',
|
|
28
|
+
});
|
|
29
|
+
console.log('libpgn cloned successfully');
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.warn('Could not clone libpgn (native addon will not be built):', err.message);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (fs.existsSync(pgnC)) {
|
|
36
|
+
runNodeGyp();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function runNodeGyp() {
|
|
40
|
+
try {
|
|
41
|
+
execSync('node-gyp rebuild', { stdio: 'inherit', cwd: path.join(__dirname, '..') });
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.warn('node-gyp rebuild failed; using JS stub:', err.message);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/binding.cc
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* N-API bindings for libpgn. Exposes Parser with nextGame() for streaming games.
|
|
3
|
+
*/
|
|
4
|
+
#include <napi.h>
|
|
5
|
+
#include <string>
|
|
6
|
+
#include <vector>
|
|
7
|
+
#include <cstring>
|
|
8
|
+
|
|
9
|
+
extern "C" {
|
|
10
|
+
#include "pgn.h"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static napi_value pgn_metadata_to_object(napi_env env, pgn_metadata_t* meta) {
|
|
14
|
+
napi_value obj;
|
|
15
|
+
napi_create_object(env, &obj);
|
|
16
|
+
if (!meta || !meta->items) return obj;
|
|
17
|
+
for (size_t i = 0; i < meta->length; i++) {
|
|
18
|
+
__pgn_metadata_item_t* item = &meta->items[i];
|
|
19
|
+
if (!item->key || !item->key->buf || !item->value || !item->value->buf) continue;
|
|
20
|
+
napi_value key_val, value_val;
|
|
21
|
+
napi_create_string_utf8(env, item->key->buf, item->key->length, &key_val);
|
|
22
|
+
napi_create_string_utf8(env, item->value->buf, item->value->length, &value_val);
|
|
23
|
+
napi_set_property(env, obj, key_val, value_val);
|
|
24
|
+
}
|
|
25
|
+
return obj;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static napi_value pgn_to_game_object(napi_env env, pgn_t* pgn) {
|
|
29
|
+
napi_value game;
|
|
30
|
+
napi_create_object(env, &game);
|
|
31
|
+
|
|
32
|
+
napi_value tags = pgn_metadata_to_object(env, pgn->metadata);
|
|
33
|
+
napi_set_named_property(env, game, "tags", tags);
|
|
34
|
+
|
|
35
|
+
const char* result_str = pgn_score_to_string(pgn->score);
|
|
36
|
+
napi_value result;
|
|
37
|
+
napi_create_string_utf8(env, result_str ? result_str : "*", NAPI_AUTO_LENGTH, &result);
|
|
38
|
+
napi_set_named_property(env, game, "result", result);
|
|
39
|
+
|
|
40
|
+
napi_value moves_array;
|
|
41
|
+
napi_create_array_with_length(env, 0, &moves_array);
|
|
42
|
+
if (pgn->moves && pgn->moves->values) {
|
|
43
|
+
size_t idx = 0;
|
|
44
|
+
for (size_t i = 0; i < pgn->moves->length; i++) {
|
|
45
|
+
napi_value white_move;
|
|
46
|
+
napi_create_string_utf8(env, pgn->moves->values[i].white.notation, NAPI_AUTO_LENGTH, &white_move);
|
|
47
|
+
napi_set_element(env, moves_array, idx++, white_move);
|
|
48
|
+
if (pgn->moves->values[i].black.notation[0] != '\0') {
|
|
49
|
+
napi_value black_move;
|
|
50
|
+
napi_create_string_utf8(env, pgn->moves->values[i].black.notation, NAPI_AUTO_LENGTH, &black_move);
|
|
51
|
+
napi_set_element(env, moves_array, idx++, black_move);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
napi_set_named_property(env, game, "moves", moves_array);
|
|
55
|
+
} else {
|
|
56
|
+
napi_set_named_property(env, game, "moves", moves_array);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return game;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class ParserWrapper : public Napi::ObjectWrap<ParserWrapper> {
|
|
63
|
+
public:
|
|
64
|
+
static Napi::Object Init(Napi::Env env, Napi::Object exports);
|
|
65
|
+
ParserWrapper(const Napi::CallbackInfo& info);
|
|
66
|
+
|
|
67
|
+
private:
|
|
68
|
+
Napi::Value NextGame(const Napi::CallbackInfo& info);
|
|
69
|
+
std::string text_;
|
|
70
|
+
size_t offset_{0};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
Napi::Object ParserWrapper::Init(Napi::Env env, Napi::Object exports) {
|
|
74
|
+
Napi::Function func = DefineClass(env, "Parser", {
|
|
75
|
+
InstanceMethod("nextGame", &ParserWrapper::NextGame),
|
|
76
|
+
});
|
|
77
|
+
Napi::Object constructor = func.Get("constructor").As<Napi::Object>();
|
|
78
|
+
exports.Set("Parser", func);
|
|
79
|
+
return exports;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
ParserWrapper::ParserWrapper(const Napi::CallbackInfo& info)
|
|
83
|
+
: Napi::ObjectWrap<ParserWrapper>(info) {
|
|
84
|
+
Napi::Env env = info.Env();
|
|
85
|
+
if (info.Length() < 1 || !info[0].IsString()) {
|
|
86
|
+
Napi::TypeError::New(env, "Expected string (pgnText)").ThrowAsJavaScriptException();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
text_ = info[0].As<Napi::String>().Utf8Value();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
Napi::Value ParserWrapper::NextGame(const Napi::CallbackInfo& info) {
|
|
93
|
+
Napi::Env env = info.Env();
|
|
94
|
+
if (offset_ >= text_.size()) {
|
|
95
|
+
return env.Null();
|
|
96
|
+
}
|
|
97
|
+
// Skip leading whitespace between games
|
|
98
|
+
while (offset_ < text_.size() && (text_[offset_] == ' ' || text_[offset_] == '\n' || text_[offset_] == '\r' || text_[offset_] == '\t')) {
|
|
99
|
+
offset_++;
|
|
100
|
+
}
|
|
101
|
+
if (offset_ >= text_.size()) {
|
|
102
|
+
return env.Null();
|
|
103
|
+
}
|
|
104
|
+
std::vector<char> mutable_buf(text_.begin() + offset_, text_.end());
|
|
105
|
+
mutable_buf.push_back('\0');
|
|
106
|
+
pgn_t* pgn = pgn_init();
|
|
107
|
+
if (!pgn) {
|
|
108
|
+
return env.Null();
|
|
109
|
+
}
|
|
110
|
+
size_t consumed = pgn_parse(pgn, mutable_buf.data());
|
|
111
|
+
if (consumed == 0) {
|
|
112
|
+
pgn_cleanup(pgn);
|
|
113
|
+
return env.Null();
|
|
114
|
+
}
|
|
115
|
+
size_t start = offset_;
|
|
116
|
+
offset_ += consumed;
|
|
117
|
+
std::string pgntext = text_.substr(start, consumed);
|
|
118
|
+
napi_value gameVal = pgn_to_game_object(env, pgn);
|
|
119
|
+
pgn_cleanup(pgn);
|
|
120
|
+
napi_value pgntextVal;
|
|
121
|
+
napi_create_string_utf8(env, pgntext.c_str(), pgntext.size(), &pgntextVal);
|
|
122
|
+
napi_set_named_property(env, gameVal, "pgntext", pgntextVal);
|
|
123
|
+
return Napi::Value(env, gameVal);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static Napi::Object Init(Napi::Env env, napi_value exports_val) {
|
|
127
|
+
return ParserWrapper::Init(env, Napi::Object(env, exports_val));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
|
package/src/index.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PGN parser — public API: parse(string) returns an array of game objects.
|
|
3
|
+
* Requires the native addon (C bindings to libpgn). Throws if the addon failed to build.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createRequire } from 'module';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
let nativeBinding = null;
|
|
10
|
+
try {
|
|
11
|
+
nativeBinding = require('./build/Release/pgn_parser.node');
|
|
12
|
+
} catch {
|
|
13
|
+
try {
|
|
14
|
+
nativeBinding = require('./build/Debug/pgn_parser.node');
|
|
15
|
+
} catch (err) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
'fast-pgn-parser: native addon failed to load. Ensure the C bindings are built (npm install / npm run rebuild). ' +
|
|
18
|
+
'You need Git, a C/C++ toolchain, and Node.js ≥18. Original error: ' + err.message
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!nativeBinding || typeof nativeBinding.Parser !== 'function') {
|
|
24
|
+
throw new Error('fast-pgn-parser: native addon did not expose Parser. Rebuild with: npm run rebuild');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Game object returned by parse().
|
|
29
|
+
* @typedef {{
|
|
30
|
+
* tags: { [key: string]: string },
|
|
31
|
+
* moves: string[],
|
|
32
|
+
* pgntext?: string
|
|
33
|
+
* }} Game
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse PGN text and return an array of game objects.
|
|
38
|
+
* Each game has `tags` (key/value headers), `moves` (array of SAN strings), and when available `pgntext` (raw PGN for that game).
|
|
39
|
+
*
|
|
40
|
+
* @param {string} pgnText - Full PGN string (one or more games).
|
|
41
|
+
* @returns {Game[]}
|
|
42
|
+
*/
|
|
43
|
+
export function parse(pgnText) {
|
|
44
|
+
const games = [];
|
|
45
|
+
const parser = new nativeBinding.Parser(pgnText);
|
|
46
|
+
while (true) {
|
|
47
|
+
const g = parser.nextGame();
|
|
48
|
+
if (g === null) break;
|
|
49
|
+
games.push({
|
|
50
|
+
tags: g.tags,
|
|
51
|
+
moves: Array.isArray(g.moves) ? g.moves : [],
|
|
52
|
+
pgntext: g.pgntext,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return games;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @returns {boolean} True if the native libpgn addon is in use (always true when the module loads successfully). */
|
|
59
|
+
export function isNative() {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { parse } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
const samplePgn = `[Event "Example"]
|
|
6
|
+
[White "A"]
|
|
7
|
+
[Black "B"]
|
|
8
|
+
|
|
9
|
+
1. e4 e5 2. Nf3 Nc6 3. Bb5 *
|
|
10
|
+
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
const twoGames = samplePgn + `
|
|
14
|
+
[Event "Second"]
|
|
15
|
+
[White "C"]
|
|
16
|
+
[Black "D"]
|
|
17
|
+
|
|
18
|
+
1. d4 d5 *
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
const pgnWithResults = `[Event "W"]
|
|
22
|
+
[White "W"]
|
|
23
|
+
[Black "B"]
|
|
24
|
+
[Result "1-0"]
|
|
25
|
+
|
|
26
|
+
1. e4 *
|
|
27
|
+
|
|
28
|
+
[Event "L"]
|
|
29
|
+
[Result "0-1"]
|
|
30
|
+
|
|
31
|
+
1. e4 e5 2. Ke2 *
|
|
32
|
+
|
|
33
|
+
[Event "D"]
|
|
34
|
+
[Result "1/2-1/2"]
|
|
35
|
+
|
|
36
|
+
1. e4 e5 2. Nf3 Nc6 3. Nf3 *
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
describe('parse', () => {
|
|
40
|
+
it('returns an array of game objects', () => {
|
|
41
|
+
const games = parse(samplePgn);
|
|
42
|
+
assert.strictEqual(games.length, 1);
|
|
43
|
+
assert.strictEqual(typeof games[0], 'object');
|
|
44
|
+
assert.ok('tags' in games[0]);
|
|
45
|
+
assert.ok('moves' in games[0]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('each game has tags (key/value) and moves (array of strings)', () => {
|
|
49
|
+
const games = parse(samplePgn);
|
|
50
|
+
assert.strictEqual(games[0].tags.Event, 'Example');
|
|
51
|
+
assert.strictEqual(games[0].tags.White, 'A');
|
|
52
|
+
assert.strictEqual(games[0].tags.Black, 'B');
|
|
53
|
+
assert.ok(Array.isArray(games[0].moves));
|
|
54
|
+
assert.ok(games[0].moves.length >= 4);
|
|
55
|
+
assert.strictEqual(games[0].moves[0], 'e4');
|
|
56
|
+
assert.strictEqual(games[0].moves[1], 'e5');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns multiple games', () => {
|
|
60
|
+
const games = parse(twoGames);
|
|
61
|
+
assert.strictEqual(games.length, 2);
|
|
62
|
+
assert.strictEqual(games[0].tags.Event, 'Example');
|
|
63
|
+
assert.strictEqual(games[1].tags.Event, 'Second');
|
|
64
|
+
assert.strictEqual(games[1].tags.White, 'C');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns empty array for empty string', () => {
|
|
68
|
+
const games = parse('');
|
|
69
|
+
assert.strictEqual(games.length, 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('each game includes pgntext when available', () => {
|
|
73
|
+
const games = parse(samplePgn);
|
|
74
|
+
assert.ok('pgntext' in games[0]);
|
|
75
|
+
assert.strictEqual(typeof games[0].pgntext, 'string');
|
|
76
|
+
assert.ok(games[0].pgntext.includes('[Event "Example"]'));
|
|
77
|
+
assert.ok(games[0].pgntext.includes('1. e4 e5'));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('pgntext is the raw PGN for that game only', () => {
|
|
81
|
+
const games = parse(twoGames);
|
|
82
|
+
assert.ok(!games[0].pgntext.includes('Second'));
|
|
83
|
+
assert.ok(games[1].pgntext.includes('[Event "Second"]'));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('pgntext contains only that game\'s tags and moves, not other games', () => {
|
|
87
|
+
const games = parse(twoGames);
|
|
88
|
+
assert.ok(games.length >= 2, 'need multiple games');
|
|
89
|
+
assert.ok(games[0].pgntext != null && games[0].pgntext.length > 0);
|
|
90
|
+
assert.ok(games[1].pgntext != null && games[1].pgntext.length > 0);
|
|
91
|
+
assert.ok(games[0].pgntext.includes('[Event "Example"]'), 'game 0 pgntext should include its Event tag');
|
|
92
|
+
assert.ok(games[0].pgntext.includes('1. e4 e5'), 'game 0 pgntext should include its moves');
|
|
93
|
+
assert.ok(!games[0].pgntext.includes('Second'), 'game 0 pgntext must not contain the other game\'s Event');
|
|
94
|
+
assert.ok(!games[0].pgntext.includes('d4'), 'game 0 pgntext must not contain the other game\'s moves');
|
|
95
|
+
assert.ok(games[1].pgntext.includes('[Event "Second"]'), 'game 1 pgntext should include its Event tag');
|
|
96
|
+
assert.ok(games[1].pgntext.includes('1. d4 d5'), 'game 1 pgntext should include its moves');
|
|
97
|
+
assert.ok(!games[1].pgntext.includes('Example'), 'game 1 pgntext must not contain the other game\'s Event');
|
|
98
|
+
assert.ok(!games[1].pgntext.includes('Bb5'), 'game 1 pgntext must not contain the other game\'s moves');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('parses games with Result tag', () => {
|
|
102
|
+
const games = parse(pgnWithResults);
|
|
103
|
+
assert.strictEqual(games.length, 3);
|
|
104
|
+
assert.strictEqual(games[0].tags.Result, '1-0');
|
|
105
|
+
assert.strictEqual(games[1].tags.Result, '0-1');
|
|
106
|
+
assert.strictEqual(games[2].tags.Result, '1/2-1/2');
|
|
107
|
+
});
|
|
108
|
+
});
|
package/vendor/.gitkeep
ADDED
|
File without changes
|
package/vendor/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Vendor: libpgn
|
|
2
|
+
|
|
3
|
+
The [libpgn](https://github.com/fwttnnn/libpgn) C library is used by the N-API addon and is **statically linked** into the native module.
|
|
4
|
+
|
|
5
|
+
## Automatic setup
|
|
6
|
+
|
|
7
|
+
Running `npm install` runs `scripts/install-libpgn.js`, which clones libpgn into **`vendor/libpgn`** if missing, then runs `node-gyp rebuild` to build the addon. If the clone or build fails, the package still installs and the JS stub parser is used.
|
|
8
|
+
|
|
9
|
+
## Manual setup
|
|
10
|
+
|
|
11
|
+
To clone libpgn yourself (e.g. for a submodule):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
git clone https://github.com/fwttnnn/libpgn.git vendor/libpgn
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then run `npm run rebuild` to build the native addon.
|