Skip to content

Commit 633a2a1

Browse files
authored
Add test infrastructure (#23)
No real tests yet, but 🔜 - Upgrade to Node v24 (maybe in 2025 the world is ready for top-level await?) - Remove `ts-node` in favor of Node native type stripping + `tsc` - Set up `assertScopes`
1 parent 6b5f2e4 commit 633a2a1

File tree

10 files changed

+736
-152
lines changed

10 files changed

+736
-152
lines changed

.github/workflows/ci.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ on:
77

88
jobs:
99
build:
10-
runs-on: ubuntu-22.04
10+
runs-on: ubuntu-24.04
1111
steps:
1212
- uses: actions/[email protected]
1313
- uses: actions/[email protected]
1414
with:
15-
node-version: 20.9
15+
node-version: 24
1616
- run: corepack enable yarn
1717
- run: yarn install --immutable
1818

19+
- run: yarn typecheck
1920
- run: yarn build
2021

2122
- name: Ensure generated files are up to date
@@ -28,3 +29,5 @@ jobs:
2829
else
2930
echo "Generated files are up to date!"
3031
fi
32+
33+
- run: yarn test:ci

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.DS_Store
2-
.yarn
2+
.yarn/*
3+
!.yarn/patches
34
.pnp.*
45
yarn-error.log
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
diff --git a/index.js b/index.js
2+
index 396c5c36b966bffddd28417e6d49ce4ae985f4d0..1a2c69693163c5572fb923fbde5b8f8377e5a32e 100644
3+
--- a/index.js
4+
+++ b/index.js
5+
@@ -136,5 +136,5 @@ export function getSafePath(path) {
6+
7+
export function getRelativeFilePath(path) {
8+
const filePath = getSafePath(path)
9+
- return new URL(filePath).pathname.replace(workspacePrefixRegex, '')
10+
+ return new URL(filePath).pathname.replace(workspacePrefixRegex, '').replace(/^\//, '')
11+
}

package.json

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,27 @@
77
"url": "https://github.com/jtbandes"
88
},
99
"license": "MIT",
10-
"packageManager": "[email protected]",
10+
"packageManager": "[email protected]",
11+
"type": "module",
1112
"scripts": {
1213
"build": "yarn build:json && yarn build:plist",
13-
"build:json": "ts-node scripts/yaml-to-json.ts -i Swift.tmLanguage.yaml -o Swift.tmLanguage.json",
14-
"build:plist": "ts-node scripts/json-to-plist.ts -i Swift.tmLanguage.json -o Syntaxes/Swift.tmLanguage"
14+
"build:json": "yarn node scripts/yaml-to-json.ts -i Swift.tmLanguage.yaml -o Swift.tmLanguage.json",
15+
"build:plist": "yarn node scripts/json-to-plist.ts -i Swift.tmLanguage.json -o Syntaxes/Swift.tmLanguage",
16+
"test": "yarn node --test test/*.test.*",
17+
"test:ci": "yarn node --test --test-reporter spec --test-reporter-destination stdout --test-reporter node-test-github-reporter --test-reporter-destination stdout test/*.test.*",
18+
"typecheck": "tsc --noEmit"
1519
},
1620
"devDependencies": {
17-
"@tsconfig/node20": "20.1.2",
18-
"@types/node": "20.8.9",
21+
"@shikijs/vscode-textmate": "10.0.2",
22+
"@tsconfig/node24": "24.0.0",
23+
"@types/node": "22.15.21",
1924
"@types/plist": "3.0.4",
2025
"commander": "11.1.0",
26+
"node-test-github-reporter": "patch:node-test-github-reporter@npm%3A1.3.0#~/.yarn/patches/node-test-github-reporter.patch",
2127
"plist": "3.1.0",
22-
"prettier": "3.0.3",
23-
"ts-node": "10.9.1",
24-
"typescript": "5.2.2",
28+
"prettier": "3.5.3",
29+
"shiki": "3.4.2",
30+
"typescript": "5.8.3",
2531
"yaml": "2.3.3"
2632
}
2733
}

scripts/yaml-to-json.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ async function main({ input, output }: { input: string; output: string }) {
2525
node.items.unshift(new Pair("comment", comment));
2626
node.commentBefore = undefined;
2727
}
28-
} else if (node instanceof YAMLSeq && node.items.length > 0 && node.items[0] instanceof YAMLMap) {
28+
} else if (
29+
node instanceof YAMLSeq &&
30+
node.items.length > 0 &&
31+
node.items[0] instanceof YAMLMap
32+
) {
2933
const map = node.items[0];
3034
if (map.has("comment")) {
3135
console.warn("warning: dropping comment", comment);

test/assert-scopes.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { $, _, createAssertScopes } from "./assert-scopes.ts";
4+
5+
const assertScopes = await createAssertScopes({
6+
name: "Test",
7+
scopeName: "source.test",
8+
repository: {},
9+
patterns: [
10+
{ match: "foo", name: "example.foo" },
11+
{ match: "bar", name: "example.bar" },
12+
],
13+
});
14+
15+
test("assertions", () => {
16+
assert.throws(
17+
() => {
18+
assertScopes(_`~~~ test`);
19+
},
20+
{ message: "Expected a source line before assertion" },
21+
);
22+
23+
assert.throws(
24+
() => {
25+
assertScopes(
26+
//
27+
$`foo bar`,
28+
_`~~~ example.foo`,
29+
);
30+
},
31+
{ message: "Not enough assertions" },
32+
);
33+
34+
assert.throws(() => {
35+
assertScopes(
36+
//
37+
$`foo bar`,
38+
_` ~~~ example.bar`,
39+
);
40+
}, /Skipped token should have no scopes/);
41+
42+
assert.throws(
43+
() => {
44+
assertScopes(
45+
//
46+
$`blah`,
47+
_`~~~~~~ wrong.scope`,
48+
);
49+
},
50+
{ message: "No token found matching assertion (0-6: wrong.scope)" },
51+
);
52+
});

test/assert-scopes.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import type { IToken } from "@shikijs/vscode-textmate";
2+
import assert from "node:assert/strict";
3+
import { createHighlighter } from "shiki";
4+
import type { Grammar, LanguageRegistration } from "shiki";
5+
6+
function assertScopes(
7+
grammar: Grammar,
8+
rootScopeName: string,
9+
...items: (string | ScopeAssertion)[]
10+
) {
11+
function stripRootScope(scopes: string[]): string[] {
12+
if (scopes[0] === rootScopeName) {
13+
return scopes.slice(1);
14+
}
15+
return scopes;
16+
}
17+
18+
let currentAssertion: ScopeAssertion | undefined;
19+
try {
20+
let state: ReturnType<Grammar["tokenizeLine"]> | undefined;
21+
let tokenIndex = 0;
22+
for (const item of items) {
23+
if (typeof item === "string") {
24+
if (state && tokenIndex < state.tokens.length) {
25+
assert.fail("Not enough assertions");
26+
}
27+
state = grammar.tokenizeLine(item, state?.ruleStack ?? null);
28+
tokenIndex = 0;
29+
continue;
30+
}
31+
currentAssertion = item;
32+
if (!state) {
33+
assert.fail("Expected a source line before assertion");
34+
}
35+
while (
36+
tokenIndex < state.tokens.length &&
37+
(state.tokens[tokenIndex]!.startIndex !== item.startIndex ||
38+
state.tokens[tokenIndex]!.endIndex !== item.endIndex)
39+
) {
40+
const token: IToken = state.tokens[tokenIndex]!;
41+
assert.deepEqual(stripRootScope(token.scopes), [], "Skipped token should have no scopes");
42+
++tokenIndex;
43+
}
44+
assert(
45+
tokenIndex < state.tokens.length,
46+
`No token found matching assertion (${item.startIndex}-${item.endIndex}: ${item.scopes.join(", ")})`,
47+
);
48+
const token = state.tokens[tokenIndex++]!;
49+
assert.deepEqual(stripRootScope(token.scopes), item.scopes);
50+
}
51+
if (state && tokenIndex < state.tokens.length) {
52+
assert.fail("Not enough assertions");
53+
}
54+
} catch (err) {
55+
if (err instanceof assert.AssertionError) {
56+
const newStack = currentAssertion?.stack;
57+
if (newStack) {
58+
const [firstLine] = err.stack?.split("\n", 1) ?? [];
59+
err.stack = firstLine ? newStack.replace(/^.*$/m, firstLine) : newStack;
60+
} else {
61+
// Hide assertScopes from the stack trace
62+
Error.captureStackTrace(err, assertScopes);
63+
}
64+
}
65+
throw err;
66+
}
67+
}
68+
69+
const assertionPattern = /~+/g;
70+
class ScopeAssertion {
71+
startIndex: number;
72+
endIndex: number;
73+
scopes: string[];
74+
stack?: string;
75+
76+
constructor(str: string) {
77+
Error.captureStackTrace(this, _); // hide stack frames below and including _
78+
assertionPattern.lastIndex = 0;
79+
const match = assertionPattern.exec(str);
80+
if (!match) {
81+
throw new Error("Expected one or more ~ in assertion string");
82+
}
83+
this.startIndex = match.index;
84+
this.endIndex = assertionPattern.lastIndex;
85+
this.scopes = str.substring(this.endIndex).trim().split(", ");
86+
}
87+
}
88+
89+
export function $(strings: TemplateStringsArray): string {
90+
return strings[0]!;
91+
}
92+
93+
export function _(strings: TemplateStringsArray): ScopeAssertion {
94+
return new ScopeAssertion(strings[0]!);
95+
}
96+
97+
export async function createAssertScopes(
98+
rawGrammar: LanguageRegistration,
99+
): Promise<(...items: (string | ScopeAssertion)[]) => void> {
100+
const highlighter = await createHighlighter({ langs: [rawGrammar], themes: [] });
101+
const grammar = highlighter.getInternalContext().getLanguage(rawGrammar.name);
102+
return assertScopes.bind(null, grammar, rawGrammar.scopeName);
103+
}

test/index.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import test from "node:test";
2+
import swiftGrammar from "../Swift.tmLanguage.json" with { type: "json" };
3+
4+
import { $, _, createAssertScopes } from "./assert-scopes.ts";
5+
import type { LanguageRegistration } from "shiki";
6+
7+
const assertScopes = await createAssertScopes(swiftGrammar as unknown as LanguageRegistration);
8+
9+
test("basic", () => {
10+
assertScopes(
11+
$`let x: String`,
12+
_`~~~ keyword.other.declaration-specifier.swift`,
13+
_` ~~~~~~ support.type.swift`,
14+
);
15+
});

tsconfig.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
{
2-
"extends": "@tsconfig/node20/tsconfig.json",
2+
"extends": "@tsconfig/node24/tsconfig.json",
33
"compilerOptions": {
4-
"noUncheckedIndexedAccess": true
4+
"noEmit": true,
5+
"resolveJsonModule": true,
6+
"noUncheckedIndexedAccess": true,
7+
"allowImportingTsExtensions": true
58
}
69
}

0 commit comments

Comments
 (0)