@razdolbai/merls 0.1.0

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.
Files changed (104) hide show
  1. package/.serena/memories/conventions.md +6 -0
  2. package/.serena/memories/core.md +8 -0
  3. package/.serena/memories/memory_maintenance.md +33 -0
  4. package/.serena/memories/suggested_commands.md +4 -0
  5. package/.serena/memories/task_completion.md +7 -0
  6. package/.serena/memories/tech_stack.md +6 -0
  7. package/.serena/project.yml +132 -0
  8. package/AGENTS.md +63 -0
  9. package/README.md +137 -0
  10. package/dist/src/asm/diagnostics.js +202 -0
  11. package/dist/src/asm/document.js +26 -0
  12. package/dist/src/asm/expression.js +163 -0
  13. package/dist/src/asm/lexer.js +122 -0
  14. package/dist/src/asm/local-labels.js +140 -0
  15. package/dist/src/asm/metadata.js +101 -0
  16. package/dist/src/asm/parser.js +118 -0
  17. package/dist/src/asm/symbols.js +40 -0
  18. package/dist/src/asm/syntax.js +44 -0
  19. package/dist/src/asm/workspace.js +73 -0
  20. package/dist/src/cli.js +21 -0
  21. package/dist/src/index.js +4 -0
  22. package/dist/src/lsp/completion.js +32 -0
  23. package/dist/src/lsp/diagnostics.js +63 -0
  24. package/dist/src/lsp/document-symbols.js +80 -0
  25. package/dist/src/lsp/hover.js +75 -0
  26. package/dist/src/lsp/symbol-navigation.js +181 -0
  27. package/dist/src/lsp/workspace-symbols.js +17 -0
  28. package/dist/src/server.js +77 -0
  29. package/dist/test/bootstrap.test.js +11 -0
  30. package/dist/test/cli-contract.test.js +74 -0
  31. package/dist/test/coc-config.test.js +21 -0
  32. package/dist/test/completion.test.js +126 -0
  33. package/dist/test/definition-references.test.js +126 -0
  34. package/dist/test/diagnostics.test.js +66 -0
  35. package/dist/test/document-model.test.js +30 -0
  36. package/dist/test/document-symbol.test.js +107 -0
  37. package/dist/test/expression.test.js +100 -0
  38. package/dist/test/fixture-corpus.test.js +33 -0
  39. package/dist/test/hover.test.js +142 -0
  40. package/dist/test/lexer.test.js +53 -0
  41. package/dist/test/line-parser.test.js +67 -0
  42. package/dist/test/local-labels.test.js +43 -0
  43. package/dist/test/metadata.test.js +27 -0
  44. package/dist/test/publish-diagnostics.test.js +137 -0
  45. package/dist/test/run-tests.js +132 -0
  46. package/dist/test/server-entrypoint.test.js +14 -0
  47. package/dist/test/server-initialize.test.js +77 -0
  48. package/dist/test/symbols.test.js +37 -0
  49. package/dist/test/syntax-shape.test.js +18 -0
  50. package/dist/test/workspace-symbol.test.js +113 -0
  51. package/dist/test/workspace.test.js +24 -0
  52. package/examples/coc-settings.json +18 -0
  53. package/package.json +26 -0
  54. package/publish.ps1 +9 -0
  55. package/src/asm/diagnostics.ts +294 -0
  56. package/src/asm/document.ts +43 -0
  57. package/src/asm/expression.ts +242 -0
  58. package/src/asm/lexer.ts +197 -0
  59. package/src/asm/local-labels.ts +204 -0
  60. package/src/asm/metadata.ts +150 -0
  61. package/src/asm/parser.ts +197 -0
  62. package/src/asm/symbols.ts +55 -0
  63. package/src/asm/syntax.ts +76 -0
  64. package/src/asm/workspace.ts +105 -0
  65. package/src/cli.ts +24 -0
  66. package/src/index.ts +1 -0
  67. package/src/lsp/completion.ts +42 -0
  68. package/src/lsp/diagnostics.ts +82 -0
  69. package/src/lsp/document-symbols.ts +111 -0
  70. package/src/lsp/hover.ts +90 -0
  71. package/src/lsp/symbol-navigation.ts +244 -0
  72. package/src/lsp/workspace-symbols.ts +24 -0
  73. package/src/server.ts +121 -0
  74. package/test/bootstrap.test.ts +7 -0
  75. package/test/cli-contract.test.ts +94 -0
  76. package/test/coc-config.test.ts +28 -0
  77. package/test/completion.test.ts +151 -0
  78. package/test/definition-references.test.ts +152 -0
  79. package/test/diagnostics.test.ts +129 -0
  80. package/test/document-model.test.ts +29 -0
  81. package/test/document-symbol.test.ts +131 -0
  82. package/test/expression.test.ts +111 -0
  83. package/test/fixture-corpus.test.ts +33 -0
  84. package/test/fixtures/invalid/65816-bank-ops.asm +17 -0
  85. package/test/fixtures/invalid/65816-long-addressing.asm +26 -0
  86. package/test/fixtures/valid/merlin32-linkscript.asm +16 -0
  87. package/test/fixtures/valid/merlin32-main-6502.asm +103 -0
  88. package/test/fixtures/valid/smoke-test.asm +7 -0
  89. package/test/hover.test.ts +175 -0
  90. package/test/lexer.test.ts +87 -0
  91. package/test/line-parser.test.ts +69 -0
  92. package/test/local-labels.test.ts +47 -0
  93. package/test/metadata.test.ts +27 -0
  94. package/test/publish-diagnostics.test.ts +206 -0
  95. package/test/run-tests.ts +139 -0
  96. package/test/server-entrypoint.test.ts +11 -0
  97. package/test/server-initialize.test.ts +101 -0
  98. package/test/smoke/run-smoke.ps1 +177 -0
  99. package/test/smoke/vimrc +17 -0
  100. package/test/symbols.test.ts +41 -0
  101. package/test/syntax-shape.test.ts +18 -0
  102. package/test/workspace-symbol.test.ts +139 -0
  103. package/test/workspace.test.ts +29 -0
  104. package/tsconfig.json +16 -0
@@ -0,0 +1,87 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import {
6
+ type LexedLine,
7
+ type Token,
8
+ lexSource
9
+ } from "../src/asm/lexer";
10
+
11
+ function summarizeTokens(tokens: readonly Token[]): readonly (readonly [string, string])[] {
12
+ return tokens.map((token) => [token.kind, token.lexeme] as const);
13
+ }
14
+
15
+ function findLine(lines: readonly LexedLine[], source: string): LexedLine {
16
+ const line = lines.find((candidate) => candidate.text === source);
17
+ assert.ok(line, `expected to find line: ${source}`);
18
+ return line;
19
+ }
20
+
21
+ export function runLexerTest(): void {
22
+ const mainFixturePath = path.resolve(
23
+ process.cwd(),
24
+ "test/fixtures/valid/merlin32-main-6502.asm"
25
+ );
26
+ const linkFixturePath = path.resolve(
27
+ process.cwd(),
28
+ "test/fixtures/valid/merlin32-linkscript.asm"
29
+ );
30
+
31
+ const mainFixture = fs.readFileSync(mainFixturePath, "utf8");
32
+ const linkFixture = fs.readFileSync(linkFixturePath, "utf8");
33
+
34
+ const mainLines = lexSource(mainFixture).lines;
35
+ const linkLines = lexSource(linkFixture).lines;
36
+
37
+ assert.deepEqual(
38
+ summarizeTokens(findLine(mainLines, "; Source: apple2accumulator/merlin32").tokens),
39
+ [["comment", "; Source: apple2accumulator/merlin32"]]
40
+ );
41
+
42
+ assert.deepEqual(
43
+ summarizeTokens(findLine(mainLines, "TEXT = $FB39").tokens),
44
+ [
45
+ ["label", "TEXT"],
46
+ ["expressionOperator", "="],
47
+ ["numericLiteral", "$FB39"]
48
+ ]
49
+ );
50
+
51
+ assert.deepEqual(
52
+ summarizeTokens(findLine(mainLines, " DUM 0").tokens),
53
+ [
54
+ ["directive", "DUM"],
55
+ ["numericLiteral", "0"]
56
+ ]
57
+ );
58
+
59
+ assert.deepEqual(
60
+ summarizeTokens(findLine(mainLines, " adc ($80,x)").tokens),
61
+ [
62
+ ["mnemonic", "adc"],
63
+ ["expressionOperator", "("],
64
+ ["numericLiteral", "$80"],
65
+ ["expressionOperator", ","],
66
+ ["identifier", "x"],
67
+ ["expressionOperator", ")"]
68
+ ]
69
+ );
70
+
71
+ assert.deepEqual(
72
+ summarizeTokens(findLine(mainLines, "GetKey ldx $C000").tokens),
73
+ [
74
+ ["label", "GetKey"],
75
+ ["mnemonic", "ldx"],
76
+ ["numericLiteral", "$C000"]
77
+ ]
78
+ );
79
+
80
+ assert.deepEqual(
81
+ summarizeTokens(findLine(linkLines, " asm \"merlin32-main-6502.asm\"").tokens),
82
+ [
83
+ ["directive", "asm"],
84
+ ["string", "\"merlin32-main-6502.asm\""]
85
+ ]
86
+ );
87
+ }
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ import { parseSourceLines } from "../src/asm/parser";
4
+
5
+ export function runLineParserTest(): void {
6
+ const source = [
7
+ "TEXT = $FB39",
8
+ " adc (_tmp+dum1+1,x)",
9
+ "dum0 ds 1",
10
+ " hex 2C",
11
+ " adc ("
12
+ ].join("\n");
13
+
14
+ const lines = parseSourceLines(source);
15
+
16
+ assert.deepEqual(lines[0], {
17
+ shape: "equate",
18
+ text: "TEXT = $FB39",
19
+ label: "TEXT",
20
+ expression: {
21
+ kind: "numericLiteral",
22
+ value: "$FB39"
23
+ }
24
+ });
25
+
26
+ assert.deepEqual(lines[1], {
27
+ shape: "instruction",
28
+ text: " adc (_tmp+dum1+1,x)",
29
+ label: null,
30
+ mnemonic: "adc",
31
+ operand: {
32
+ immediate: false,
33
+ indirect: true,
34
+ indexRegister: "x",
35
+ expression: {
36
+ kind: "binary",
37
+ operator: "+",
38
+ left: {
39
+ kind: "binary",
40
+ operator: "+",
41
+ left: { kind: "identifier", value: "_tmp" },
42
+ right: { kind: "identifier", value: "dum1" }
43
+ },
44
+ right: { kind: "numericLiteral", value: "1" }
45
+ }
46
+ }
47
+ });
48
+
49
+ assert.deepEqual(lines[2], {
50
+ shape: "directive",
51
+ text: "dum0 ds 1",
52
+ label: "dum0",
53
+ directive: "ds",
54
+ operand: {
55
+ kind: "numericLiteral",
56
+ value: "1"
57
+ }
58
+ });
59
+
60
+ assert.deepEqual(lines[3], {
61
+ shape: "data",
62
+ text: " hex 2C",
63
+ label: null,
64
+ directive: "hex",
65
+ payload: "2C"
66
+ });
67
+
68
+ assert.equal(lines[4]?.shape, "malformed");
69
+ }
@@ -0,0 +1,47 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { parseDocument } from "../src/asm/document";
6
+ import { resolveLocalLabels } from "../src/asm/local-labels";
7
+
8
+ export function runLocalLabelScopeTest(): void {
9
+ const fixturePath = path.resolve(
10
+ process.cwd(),
11
+ "test/fixtures/valid/merlin32-main-6502.asm"
12
+ );
13
+ const source = fs.readFileSync(fixturePath, "utf8");
14
+
15
+ const document = parseDocument(source);
16
+ const scope = resolveLocalLabels(document);
17
+
18
+ assert.deepEqual(scope.definitions.get("]loop@69"), {
19
+ name: "]loop",
20
+ line: 71,
21
+ anchor: "GetKey",
22
+ qualifiedName: "]loop@69"
23
+ });
24
+
25
+ assert.deepEqual(scope.references.get("]loop@73"), {
26
+ name: "]loop",
27
+ line: 73,
28
+ anchor: "GetKey",
29
+ qualifiedName: "]loop@69",
30
+ targetLine: 71
31
+ });
32
+
33
+ assert.deepEqual(scope.definitions.get(":err@69"), {
34
+ name: ":err",
35
+ line: 82,
36
+ anchor: "GetKey",
37
+ qualifiedName: ":err@69"
38
+ });
39
+
40
+ assert.deepEqual(scope.references.get(":good@81"), {
41
+ name: ":good",
42
+ line: 81,
43
+ anchor: "GetKey",
44
+ qualifiedName: ":good@69",
45
+ targetLine: 84
46
+ });
47
+ }
@@ -0,0 +1,27 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ import {
4
+ directiveTable,
5
+ opcodeTable
6
+ } from "../src/asm/metadata";
7
+
8
+ export function runMetadataTableTest(): void {
9
+ assert.equal(opcodeTable.size, 56);
10
+ assert.deepEqual(opcodeTable.get("lda")?.modes, [
11
+ "immediate",
12
+ "zeroPage",
13
+ "zeroPageX",
14
+ "absolute",
15
+ "absoluteX",
16
+ "absoluteY",
17
+ "indexedIndirect",
18
+ "indirectIndexed"
19
+ ]);
20
+ assert.equal(opcodeTable.has("mvn"), false);
21
+
22
+ assert.equal(directiveTable.get("org")?.supported, true);
23
+ assert.equal(directiveTable.get("dum")?.supported, true);
24
+ assert.equal(directiveTable.get("xc")?.supported, false);
25
+ assert.equal(directiveTable.get("mx")?.supported, false);
26
+ assert.equal(directiveTable.get("put")?.kind, "include");
27
+ }
@@ -0,0 +1,206 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+
5
+ type JsonRpcMessage = {
6
+ id?: number;
7
+ jsonrpc: "2.0";
8
+ method?: string;
9
+ params?: unknown;
10
+ result?: unknown;
11
+ };
12
+
13
+ type PublishedDiagnostic = {
14
+ message: string;
15
+ severity?: number;
16
+ range: {
17
+ start: {
18
+ line: number;
19
+ character: number;
20
+ };
21
+ end: {
22
+ line: number;
23
+ character: number;
24
+ };
25
+ };
26
+ };
27
+
28
+ type PublishDiagnosticsParams = {
29
+ uri: string;
30
+ diagnostics: PublishedDiagnostic[];
31
+ };
32
+
33
+ function encodeMessage(message: object): string {
34
+ const body = JSON.stringify(message);
35
+ return `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
36
+ }
37
+
38
+ function decodeMessages(streamBuffer: string): { messages: JsonRpcMessage[]; rest: string } {
39
+ const messages: JsonRpcMessage[] = [];
40
+ let buffer = streamBuffer;
41
+
42
+ for (;;) {
43
+ const separator = buffer.indexOf("\r\n\r\n");
44
+ if (separator === -1) {
45
+ return { messages, rest: buffer };
46
+ }
47
+
48
+ const header = buffer.slice(0, separator);
49
+ const match = /Content-Length: (\d+)/i.exec(header);
50
+ if (!match) {
51
+ throw new Error(`Missing Content-Length header: ${header}`);
52
+ }
53
+
54
+ const length = Number(match[1]);
55
+ const body = buffer.slice(separator + 4);
56
+ if (Buffer.byteLength(body, "utf8") < length) {
57
+ return { messages, rest: buffer };
58
+ }
59
+
60
+ messages.push(JSON.parse(body.slice(0, length)) as JsonRpcMessage);
61
+ buffer = body.slice(length);
62
+ }
63
+ }
64
+
65
+ export async function runPublishDiagnosticsTest(): Promise<void> {
66
+ const serverPath = path.resolve(__dirname, "../src/server.js");
67
+ const documentPath = path.resolve(
68
+ process.cwd(),
69
+ "test/fixtures/invalid/publish-diagnostics.asm"
70
+ );
71
+ const documentUri = `file://${documentPath.replace(/\\/g, "/")}`;
72
+ const brokenText = ["dup equ 1", " lda missing", "dup equ 2", " adc ("].join(
73
+ "\n"
74
+ );
75
+ const fixedText = ["dup equ 1", " lda dup", " adc #1"].join("\n");
76
+ const child = spawn(process.execPath, [serverPath], {
77
+ stdio: ["pipe", "pipe", "pipe"]
78
+ });
79
+
80
+ let stdout = "";
81
+ let nextId = 1;
82
+ const pending = new Map<number, (message: JsonRpcMessage) => void>();
83
+ const diagnosticWaiters: Array<(params: PublishDiagnosticsParams) => void> = [];
84
+
85
+ child.stdout.setEncoding("utf8");
86
+ child.stdout.on("data", (chunk: string) => {
87
+ stdout += chunk;
88
+ const decoded = decodeMessages(stdout);
89
+ stdout = decoded.rest;
90
+
91
+ for (const message of decoded.messages) {
92
+ if (message.id !== undefined) {
93
+ pending.get(message.id)?.(message);
94
+ pending.delete(message.id);
95
+ continue;
96
+ }
97
+
98
+ if (message.method === "textDocument/publishDiagnostics") {
99
+ const params = message.params as PublishDiagnosticsParams;
100
+ const waiter = diagnosticWaiters.shift();
101
+ waiter?.(params);
102
+ }
103
+ }
104
+ });
105
+
106
+ function sendRequest(method: string, params: object): Promise<JsonRpcMessage> {
107
+ const id = nextId++;
108
+ child.stdin.write(
109
+ encodeMessage({
110
+ id,
111
+ jsonrpc: "2.0",
112
+ method,
113
+ params
114
+ })
115
+ );
116
+
117
+ return new Promise((resolve) => {
118
+ pending.set(id, resolve);
119
+ });
120
+ }
121
+
122
+ function sendNotification(method: string, params: object): void {
123
+ child.stdin.write(
124
+ encodeMessage({
125
+ jsonrpc: "2.0",
126
+ method,
127
+ params
128
+ })
129
+ );
130
+ }
131
+
132
+ function waitForDiagnostics(): Promise<PublishDiagnosticsParams> {
133
+ return new Promise((resolve) => {
134
+ diagnosticWaiters.push(resolve);
135
+ });
136
+ }
137
+
138
+ try {
139
+ await sendRequest("initialize", {
140
+ capabilities: {},
141
+ processId: process.pid,
142
+ rootUri: `file://${path.resolve(process.cwd()).replace(/\\/g, "/")}`
143
+ });
144
+
145
+ sendNotification("initialized", {});
146
+
147
+ const openedDiagnostics = waitForDiagnostics();
148
+ sendNotification("textDocument/didOpen", {
149
+ textDocument: {
150
+ uri: documentUri,
151
+ languageId: "asm",
152
+ version: 1,
153
+ text: brokenText
154
+ }
155
+ });
156
+
157
+ const firstPublish = await openedDiagnostics;
158
+ assert.equal(firstPublish.uri, documentUri);
159
+ assert.equal(
160
+ firstPublish.diagnostics.some(
161
+ (diagnostic) =>
162
+ diagnostic.message.includes("Unresolved reference missing") &&
163
+ diagnostic.severity === 1 &&
164
+ diagnostic.range.start.line === 1
165
+ ),
166
+ true
167
+ );
168
+ assert.equal(
169
+ firstPublish.diagnostics.some(
170
+ (diagnostic) =>
171
+ diagnostic.message.includes("Duplicate symbol dup") &&
172
+ diagnostic.severity === 1 &&
173
+ diagnostic.range.start.line === 2
174
+ ),
175
+ true
176
+ );
177
+ assert.equal(
178
+ firstPublish.diagnostics.some(
179
+ (diagnostic) =>
180
+ diagnostic.message.includes("expected expression token") &&
181
+ diagnostic.severity === 1 &&
182
+ diagnostic.range.start.line === 3
183
+ ),
184
+ true
185
+ );
186
+
187
+ const changedDiagnostics = waitForDiagnostics();
188
+ sendNotification("textDocument/didChange", {
189
+ textDocument: {
190
+ uri: documentUri,
191
+ version: 2
192
+ },
193
+ contentChanges: [
194
+ {
195
+ text: fixedText
196
+ }
197
+ ]
198
+ });
199
+
200
+ const secondPublish = await changedDiagnostics;
201
+ assert.equal(secondPublish.uri, documentUri);
202
+ assert.deepEqual(secondPublish.diagnostics, []);
203
+ } finally {
204
+ child.kill();
205
+ }
206
+ }
@@ -0,0 +1,139 @@
1
+ import { runBootstrapTest } from "./bootstrap.test";
2
+ import { runCliContractTest } from "./cli-contract.test";
3
+ import { runCocConfigTest } from "./coc-config.test";
4
+ import { runCompletionTest } from "./completion.test";
5
+ import { runDefinitionReferencesTest } from "./definition-references.test";
6
+ import { runDiagnosticsTest } from "./diagnostics.test";
7
+ import { runDocumentModelTest } from "./document-model.test";
8
+ import { runDocumentSymbolTest } from "./document-symbol.test";
9
+ import { runExpressionTest } from "./expression.test";
10
+ import { runFixtureCorpusTest } from "./fixture-corpus.test";
11
+ import { runHoverTest } from "./hover.test";
12
+ import { runLexerTest } from "./lexer.test";
13
+ import { runLineParserTest } from "./line-parser.test";
14
+ import { runLocalLabelScopeTest } from "./local-labels.test";
15
+ import { runMetadataTableTest } from "./metadata.test";
16
+ import { runPublishDiagnosticsTest } from "./publish-diagnostics.test";
17
+ import { runInitializeHandshakeTest } from "./server-initialize.test";
18
+ import { runServerEntrypointTest } from "./server-entrypoint.test";
19
+ import { runSymbolsTest } from "./symbols.test";
20
+ import { runSyntaxShapeTest } from "./syntax-shape.test";
21
+ import { runWorkspaceGraphTest } from "./workspace.test";
22
+ import { runWorkspaceSymbolTest } from "./workspace-symbol.test";
23
+
24
+ type TestCase = {
25
+ name: string;
26
+ run: () => void | Promise<void>;
27
+ };
28
+
29
+ const tests: TestCase[] = [
30
+ {
31
+ name: "workspace bootstrap exposes the project name",
32
+ run: runBootstrapTest
33
+ },
34
+ {
35
+ name: "cli contract exposes a packaged stdio entrypoint",
36
+ run: runCliContractTest
37
+ },
38
+ {
39
+ name: "coc.nvim example targets the packaged stdio CLI contract",
40
+ run: runCocConfigTest
41
+ },
42
+ {
43
+ name: "server entrypoint exposes callable startup helpers",
44
+ run: runServerEntrypointTest
45
+ },
46
+ {
47
+ name: "server process answers initialize",
48
+ run: runInitializeHandshakeTest
49
+ },
50
+ {
51
+ name: "positive fixture corpus includes upstream Merlin32 samples",
52
+ run: runFixtureCorpusTest
53
+ },
54
+ {
55
+ name: "shared opcode and directive metadata covers 6502 and Merlin syntax",
56
+ run: runMetadataTableTest
57
+ },
58
+ {
59
+ name: "token kinds and line shapes cover Merlin syntax categories",
60
+ run: runSyntaxShapeTest
61
+ },
62
+ {
63
+ name: "lexer tokenizes fixture comments labels mnemonics directives and literals",
64
+ run: runLexerTest
65
+ },
66
+ {
67
+ name: "expression parser handles numeric forms modifiers arithmetic and indexed operands",
68
+ run: runExpressionTest
69
+ },
70
+ {
71
+ name: "line parser recognizes equates instructions directives data and malformed lines",
72
+ run: runLineParserTest
73
+ },
74
+ {
75
+ name: "document model preserves line structure and tolerates malformed lines",
76
+ run: runDocumentModelTest
77
+ },
78
+ {
79
+ name: "symbol collection indexes labels equates and named data definitions",
80
+ run: runSymbolsTest
81
+ },
82
+ {
83
+ name: "local label scope resolves Merlin local definitions and references",
84
+ run: runLocalLabelScopeTest
85
+ },
86
+ {
87
+ name: "workspace indexing follows Merlin include directives and merges symbols",
88
+ run: runWorkspaceGraphTest
89
+ },
90
+ {
91
+ name: "diagnostics report duplicates unresolved refs malformed lines and unsupported 65816 syntax",
92
+ run: runDiagnosticsTest
93
+ },
94
+ {
95
+ name: "server returns document symbols for Merlin labels equates and data definitions",
96
+ run: runDocumentSymbolTest
97
+ },
98
+ {
99
+ name: "server returns workspace symbols across open Merlin documents",
100
+ run: runWorkspaceSymbolTest
101
+ },
102
+ {
103
+ name: "server resolves definitions and references for Merlin symbols",
104
+ run: runDefinitionReferencesTest
105
+ },
106
+ {
107
+ name: "server returns hover information for opcodes directives and symbols",
108
+ run: runHoverTest
109
+ },
110
+ {
111
+ name: "server returns opcode directive and symbol completions",
112
+ run: runCompletionTest
113
+ },
114
+ {
115
+ name: "server publishes and clears diagnostics for open Merlin documents",
116
+ run: runPublishDiagnosticsTest
117
+ }
118
+ ];
119
+
120
+ async function main(): Promise<void> {
121
+ let failures = 0;
122
+
123
+ for (const test of tests) {
124
+ try {
125
+ await test.run();
126
+ console.log(`PASS ${test.name}`);
127
+ } catch (error) {
128
+ failures += 1;
129
+ console.error(`FAIL ${test.name}`);
130
+ console.error(error);
131
+ }
132
+ }
133
+
134
+ if (failures > 0) {
135
+ process.exitCode = 1;
136
+ }
137
+ }
138
+
139
+ void main();
@@ -0,0 +1,11 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ import { createServerConnection, startServer } from "../src/server";
4
+
5
+ export function runServerEntrypointTest(): void {
6
+ assert.equal(typeof startServer, "function");
7
+
8
+ const connection = createServerConnection();
9
+ assert.equal(typeof connection.listen, "function");
10
+ connection.dispose();
11
+ }
@@ -0,0 +1,101 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import path from "node:path";
4
+
5
+ type JsonRpcMessage = {
6
+ id?: number;
7
+ jsonrpc: "2.0";
8
+ method?: string;
9
+ result?: {
10
+ capabilities?: {
11
+ definitionProvider?: boolean;
12
+ referencesProvider?: boolean;
13
+ textDocumentSync?: {
14
+ change?: number;
15
+ openClose?: boolean;
16
+ };
17
+ };
18
+ };
19
+ };
20
+
21
+ function encodeMessage(message: object): string {
22
+ const body = JSON.stringify(message);
23
+ return `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
24
+ }
25
+
26
+ export async function runInitializeHandshakeTest(): Promise<void> {
27
+ const serverPath = path.resolve(__dirname, "../src/server.js");
28
+ const child = spawn(process.execPath, [serverPath], {
29
+ stdio: ["pipe", "pipe", "pipe"]
30
+ });
31
+
32
+ const response = new Promise<JsonRpcMessage>((resolve, reject) => {
33
+ let stdout = "";
34
+ let stderr = "";
35
+
36
+ child.stdout.setEncoding("utf8");
37
+ child.stderr.setEncoding("utf8");
38
+
39
+ child.stdout.on("data", (chunk: string) => {
40
+ stdout += chunk;
41
+
42
+ const separator = stdout.indexOf("\r\n\r\n");
43
+ if (separator === -1) {
44
+ return;
45
+ }
46
+
47
+ const header = stdout.slice(0, separator);
48
+ const match = /Content-Length: (\d+)/i.exec(header);
49
+ if (!match) {
50
+ reject(new Error(`Missing Content-Length header in response: ${stdout}`));
51
+ return;
52
+ }
53
+
54
+ const length = Number(match[1]);
55
+ const body = stdout.slice(separator + 4);
56
+ if (Buffer.byteLength(body, "utf8") < length) {
57
+ return;
58
+ }
59
+
60
+ resolve(JSON.parse(body.slice(0, length)) as JsonRpcMessage);
61
+ });
62
+
63
+ child.stderr.on("data", (chunk: string) => {
64
+ stderr += chunk;
65
+ });
66
+
67
+ child.once("error", reject);
68
+ child.once("exit", (code) => {
69
+ reject(new Error(`Server exited before initialize response. code=${code}, stderr=${stderr}`));
70
+ });
71
+ });
72
+
73
+ child.stdin.write(
74
+ encodeMessage({
75
+ id: 1,
76
+ jsonrpc: "2.0",
77
+ method: "initialize",
78
+ params: {
79
+ capabilities: {},
80
+ clientInfo: {
81
+ name: "merls-test"
82
+ },
83
+ processId: process.pid,
84
+ rootUri: null
85
+ }
86
+ })
87
+ );
88
+
89
+ try {
90
+ const message = await response;
91
+ assert.equal(message.id, 1);
92
+ assert.equal(message.jsonrpc, "2.0");
93
+ assert.equal(typeof message.result?.capabilities, "object");
94
+ assert.equal(message.result?.capabilities?.textDocumentSync?.openClose, true);
95
+ assert.equal(message.result?.capabilities?.textDocumentSync?.change, 1);
96
+ assert.equal(message.result?.capabilities?.definitionProvider, true);
97
+ assert.equal(message.result?.capabilities?.referencesProvider, true);
98
+ } finally {
99
+ child.kill();
100
+ }
101
+ }