Skip to content

Commit b72574a

Browse files
authored
feat: jsonl mode (#7)
1 parent a850d23 commit b72574a

8 files changed

Lines changed: 316 additions & 0 deletions

File tree

readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Adds CodeMirror language support for languages that are not bundled with Acode.
1212
- Git attributes (`.gitattributes`, Git attributes-style files)
1313
- Git Commit Message (`^COMMIT_EDITMSG`, `^commit_editmsg`)
1414
- JSONC (`.jsonc`)
15+
- JSON Lines (`.jsonl`, `.ndjson`, `.jsonlines`, `.ldjson`, `.ldj`)
1516
- YAML Enhanced (`.yaml`, `.yml`), with structural parsing and core-schema scalar highlighting
1617
- BibTeX (`.bib`)
1718
- Elixir (`.ex`, `.exs`, `.eex`, `.heex`, `.leex`)

scripts/test-parser.mjs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,170 @@ for (const sample of jsoncSamples) {
179179

180180
console.log(`Validated ${jsoncSamples.length} JSONC parser fixtures.`);
181181

182+
// Test JSONL Parser
183+
const { parser: jsonlParser } = await bundledRequire(
184+
"src/languages/jsonl/parser.js",
185+
"acode-jsonl-parser-test.cjs",
186+
);
187+
188+
const jsonlSamples = [
189+
{
190+
name: "JSON Lines multiple objects and comments",
191+
source: `{"id": 1, "name": "Acode", "active": true}
192+
{"id": 2, "name": "CodeMirror", "meta": null, "count": -3.14e+2}
193+
// Line comment between records
194+
/* Block comment */
195+
{"items": [1, "two", false]}
196+
"plain string line"
197+
12345
198+
true
199+
null
200+
`,
201+
nodes: [
202+
"Object",
203+
"Property",
204+
"PropertyName",
205+
"Number",
206+
"String",
207+
"True",
208+
"False",
209+
"Null",
210+
"Array",
211+
"LineComment",
212+
"BlockComment",
213+
],
214+
},
215+
];
216+
217+
for (const sample of jsonlSamples) {
218+
const tree = jsonlParser.parse(sample.source);
219+
const names = new Set();
220+
const errors = [];
221+
222+
tree.iterate({
223+
enter(node) {
224+
names.add(node.name);
225+
if (node.type.isError) {
226+
errors.push([node.from, node.to]);
227+
}
228+
},
229+
});
230+
231+
assert.equal(errors.length, 0, `${sample.name} produced parse errors`);
232+
assert.equal(tree.length, sample.source.length, `${sample.name} was not fully parsed`);
233+
for (const node of sample.nodes) {
234+
assert(names.has(node), `${sample.name} did not produce ${node}`);
235+
}
236+
}
237+
238+
console.log(`Validated ${jsonlSamples.length} JSONL parser fixtures.`);
239+
240+
const jsonlBehaviorBundle = path.join(os.tmpdir(), "acode-jsonl-behavior-test-bundle.mjs");
241+
const jsonlBehaviorSource = String.raw`
242+
import assert from "node:assert/strict";
243+
import { CompletionContext } from "@codemirror/autocomplete";
244+
import { EditorState } from "@codemirror/state";
245+
import { foldable, getIndentation, indentUnit, syntaxTree } from "@codemirror/language";
246+
import { classHighlighter, highlightTree } from "@lezer/highlight";
247+
import { jsonl, jsonlLanguage } from "./src/languages/jsonl/index.js";
248+
249+
const source = [
250+
'{"id": 1, "name": "Acode", "valid": true}',
251+
'// comment line',
252+
'{"tags": ["editor", "mobile"], "meta": null, "score": -4.5e1}',
253+
'/* block comment */',
254+
'{"nested": {',
255+
' "key": "value"',
256+
'}}',
257+
'',
258+
].join("\n");
259+
260+
const tree = jsonlLanguage.parser.parse(source);
261+
const names = new Set();
262+
const errors = [];
263+
tree.iterate({
264+
enter(node) {
265+
names.add(node.name);
266+
if (node.type.isError) errors.push([node.from, node.to]);
267+
},
268+
});
269+
270+
assert.equal(errors.length, 0, "JSONL fixture produced parse errors");
271+
for (const name of [
272+
"Object",
273+
"Property",
274+
"PropertyName",
275+
"Number",
276+
"String",
277+
"True",
278+
"Null",
279+
"Array",
280+
"LineComment",
281+
"BlockComment",
282+
]) {
283+
assert(names.has(name), "JSONL fixture did not produce " + name);
284+
}
285+
286+
const spans = [];
287+
highlightTree(tree, classHighlighter, (from, to, cls) => {
288+
spans.push({ text: source.slice(from, to), cls });
289+
});
290+
291+
function isHighlighted(text, tokenClass) {
292+
return spans.some((span) => span.text === text && span.cls.includes(tokenClass));
293+
}
294+
295+
assert(isHighlighted('"name"', "tok-propertyName"));
296+
assert(isHighlighted('"Acode"', "tok-string"));
297+
assert(isHighlighted("1", "tok-number"));
298+
assert(isHighlighted("-4.5e1", "tok-number"));
299+
assert(isHighlighted("true", "tok-bool"));
300+
assert(isHighlighted("null", "tok-keyword"));
301+
assert(isHighlighted('// comment line', "tok-comment"));
302+
assert(isHighlighted('/* block comment */', "tok-comment"));
303+
304+
const indentSource = '{\n "nested": {\n "key": "val"\n }\n}\n';
305+
const indentState = EditorState.create({
306+
doc: indentSource,
307+
extensions: [jsonl(), indentUnit.of(" ")],
308+
});
309+
syntaxTree(indentState);
310+
assert.equal(getIndentation(indentState, indentState.doc.line(2).from), 2);
311+
assert.equal(getIndentation(indentState, indentState.doc.line(3).from), 4);
312+
assert.equal(getIndentation(indentState, indentState.doc.line(4).from), 2);
313+
assert.equal(getIndentation(indentState, indentState.doc.line(5).from), 0);
314+
assert(foldable(indentState, indentState.doc.line(1).from, indentState.doc.line(1).to));
315+
316+
const compState = EditorState.create({ doc: "tr", extensions: [jsonl()] });
317+
const compSource = compState.languageDataAt("autocomplete", 2)[0];
318+
const compResult = compSource(new CompletionContext(compState, 2, true));
319+
assert(compResult.options.some((opt) => opt.label === "true"));
320+
assert(compResult.options.some((opt) => opt.label === "false"));
321+
assert(compResult.options.some((opt) => opt.label === "null"));
322+
323+
console.log("Validated JSONL parsing, highlighting, indentation, folding, and completion fixtures.");
324+
`;
325+
326+
await build({
327+
stdin: {
328+
contents: jsonlBehaviorSource,
329+
resolveDir: process.cwd(),
330+
sourcefile: "jsonl-behavior-test.mjs",
331+
loader: "js",
332+
},
333+
bundle: true,
334+
format: "esm",
335+
platform: "node",
336+
outfile: jsonlBehaviorBundle,
337+
logLevel: "silent",
338+
});
339+
340+
try {
341+
await import(pathToFileURL(jsonlBehaviorBundle).href + `?t=${Date.now()}`);
342+
} finally {
343+
fs.rmSync(jsonlBehaviorBundle, { force: true });
344+
}
345+
182346
const yamlBehaviorBundle = path.join(os.tmpdir(), "acode-yaml-behavior-test-bundle.mjs");
183347
const yamlBehaviorSource = String.raw`
184348
import assert from "node:assert/strict";

scripts/test-plugin-runtime.cjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ async function testHostRuntime() {
6363
"zig",
6464
"gitignore",
6565
"jsonc",
66+
"jsonl",
6667
"yaml-enhanced",
6768
"bibtex",
6869
"elixir",
@@ -90,6 +91,10 @@ async function testHostRuntime() {
9091
assert(modes.get("zig").extensions.includes("zon"));
9192
assert(modes.get("gitignore").extensions.includes("gitignore"));
9293
assert(modes.get("jsonc").extensions.includes("jsonc"));
94+
assert(modes.get("jsonl").extensions.includes("jsonl"));
95+
assert(modes.get("jsonl").extensions.includes("ndjson"));
96+
assert(modes.get("jsonl").extensions.includes("jsonlines"));
97+
assert(modes.get("jsonl").extensions.includes("ldjson"));
9398
assert(modes.get("yaml-enhanced").extensions.includes("yaml"));
9499
assert(modes.get("yaml-enhanced").extensions.includes("yml"));
95100
assert(modes.get('gitcommitmsg').extensions.includes("^COMMIT_EDITMSG"));
@@ -131,6 +136,7 @@ message:
131136
zig: 'const std = @import("std");',
132137
gitignore: "# build output\ndist/\n!important.log\n*.tmp\n",
133138
jsonc: '{\n // comment\n "foo": "bar",\n}',
139+
jsonl: '{"id": 1, "name": "Acode"}\n{"id": 2, "name": "CodeMirror", "active": true}\n// comment\n[1, 2, 3]\n',
134140
"yaml-enhanced": `defaults: &defaults
135141
enabled: true
136142
retries: 3

src/languages/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { communityLanguageModes } from "./community";
55
import { gitignoreMode } from "./gitignore";
66
import { zigMode } from "./zig";
77
import { jsoncMode } from "./jsonc";
8+
import { jsonlMode } from "./jsonl";
89
import { yamlMode } from "./yaml";
910

1011
import { ejsMode } from "./ejs"
@@ -26,6 +27,7 @@ export const languageModes = [
2627
zigMode,
2728
gitignoreMode,
2829
jsoncMode,
30+
jsonlMode,
2931
yamlMode,
3032
...communityLanguageModes,
3133
ejsMode,

src/languages/jsonl/index.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { completeFromList } from "@codemirror/autocomplete";
2+
import {
3+
delimitedIndent,
4+
foldInside,
5+
foldNodeProp,
6+
indentNodeProp,
7+
LanguageSupport,
8+
LRLanguage,
9+
} from "@codemirror/language";
10+
import { styleTags, tags as t } from "@lezer/highlight";
11+
import { parser } from "./parser";
12+
13+
const configuredParser = parser.configure({
14+
props: [
15+
styleTags({
16+
True: t.bool,
17+
False: t.bool,
18+
Null: t.null,
19+
Number: t.number,
20+
String: t.string,
21+
PropertyName: t.propertyName,
22+
LineComment: t.lineComment,
23+
BlockComment: t.blockComment,
24+
"Object/\"{\" Object/\"}\"": t.brace,
25+
"Array/\"[\" Array/\"]\"": t.squareBracket,
26+
"\",\" \":\"": t.punctuation,
27+
}),
28+
indentNodeProp.add({
29+
Object: delimitedIndent({ closing: "}" }),
30+
Array: delimitedIndent({ closing: "]" }),
31+
}),
32+
foldNodeProp.add({
33+
Object: foldInside,
34+
Array: foldInside,
35+
BlockComment: foldInside,
36+
}),
37+
],
38+
});
39+
40+
export const jsonlLanguage = LRLanguage.define({
41+
name: "jsonl",
42+
parser: configuredParser,
43+
languageData: {
44+
commentTokens: {
45+
line: "//",
46+
block: { open: "/*", close: "*/" },
47+
},
48+
closeBrackets: { brackets: ["{", "[", '"'] },
49+
indentOnInput: /^\s*[\}\]]$/,
50+
},
51+
});
52+
53+
const jsonlCompletion = jsonlLanguage.data.of({
54+
autocomplete: completeFromList([
55+
{ label: "true", type: "keyword" },
56+
{ label: "false", type: "keyword" },
57+
{ label: "null", type: "keyword" },
58+
]),
59+
});
60+
61+
export function jsonl() {
62+
return new LanguageSupport(jsonlLanguage, [jsonlCompletion]);
63+
}
64+
65+
export const jsonlMode = {
66+
name: "jsonl",
67+
caption: "JSON Lines",
68+
extensions: ["jsonl", "ndjson", "jsonlines", "ldjson", "ldj"],
69+
load: jsonl,
70+
};

src/languages/jsonl/jsonl.grammar

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
@top JsonText { value* }
2+
3+
value { True | False | Null | Number | String | Object | Array }
4+
5+
String[isolate] { string }
6+
Object { "{" list<Property>? "}" }
7+
Array { "[" list<value>? "]" }
8+
9+
Property { PropertyName ":" value }
10+
PropertyName[isolate] { string }
11+
12+
@tokens {
13+
True { "true" }
14+
False { "false" }
15+
Null { "null" }
16+
17+
Number { '-'? int frac? exp? }
18+
int { '0' | $[1-9] @digit* }
19+
frac { '.' @digit+ }
20+
exp { $[eE] $[+\-]? @digit+ }
21+
22+
string { '"' char* '"' }
23+
char { $[\u{20}\u{21}\u{23}-\u{5b}\u{5d}-\u{10ffff}] | "\\" esc }
24+
esc { $["\\\/bfnrt] | "u" hex hex hex hex }
25+
hex { $[0-9a-fA-F] }
26+
27+
LineComment { "//" ![\n\r]* }
28+
BlockComment { "/*" (![*] | "*" ![/])* "*/" }
29+
30+
whitespace { $[ \n\r\t]+ }
31+
32+
"{" "}" "[" "]" "," ":"
33+
}
34+
35+
@skip { whitespace | LineComment | BlockComment }
36+
list<item> { item ("," item)* ","? }
37+
38+
@detectDelim

src/languages/jsonl/parser.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// This file was generated by lezer-generator. You probably shouldn't edit it.
2+
import {LRParser} from "@lezer/lr"
3+
export const parser = LRParser.deserialize({
4+
version: 14,
5+
states: "%WQ]QPOOOOQO'#Cd'#CdOtQPO'#CgO|QPO'#CnOOQO'#Cu'#CuOOQO'#Co'#CoQ]QPOOOOQO'#Ci'#CiO!TQPO'#ChO!YQPO'#CwOOQO,59R,59RO!bQPO,59RO!gQPO'#CxOOQO,59Y,59YO!oQPO,59YOOQO-E6m-E6mO]QPO,59SO!tQPO,59cO!|QPO,59cOOQO1G.m1G.mO#UQPO,59dO#]QPO,59dOOQO1G.t1G.tOOQO1G.n1G.nOOQO,59[,59[O#eQPO1G.}OOQO-E6n-E6nOOQO,59],59]O#mQPO1G/OOOQO-E6o-E6oPwQPO'#CpP]QPO'#Cq",
6+
stateData: "#t~OhOSPOSQOS~OSSOTSOUSOVSOYQOaROjPO~OXYOjVO~O`]O~P]O^`O~O_aOXkX~OXcO~O_dO`lX~O`fO~OjVOXka~O_iOXka~O`la~P]O_lO`la~OjVOXki~O`li~P]O",
7+
goto: "#OmPPPPPPPPnPPnw!PPPPPn!V!]!cPPP!iP!x!{_SORU`dloQXQVhainXWQainQUOR_UQbXRjbQe[RmeSTOUQ[RQg`VkdloRZQR^R",
8+
nodeNames: "⚠ LineComment BlockComment JsonText True False Null Number String } { Object Property PropertyName : , ] [ Array",
9+
maxTerm: 28,
10+
nodeProps: [
11+
["isolate", -2,8,13,""],
12+
["openedBy", 9,"{",16,"["],
13+
["closedBy", 10,"}",17,"]"]
14+
],
15+
skippedNodes: [0,1,2],
16+
repeatNodeCount: 3,
17+
tokenData: "+R~RbXY!ZYZ!Z]^!Zpq!Zrs!l|}%U}!O%Z!P!Q'T!Q!R%d!R![&r![!](y!}#O)O#P#Q)T#Y#Z)Y#b#c)w#h#i*`#o#p*w#q#r*|~!`Sh~XY!ZYZ!Z]^!Zpq!Z~!oWpq!lqr!lrs#Xs#O!l#O#P#^#P;'S!l;'S;=`%O<%lO!l~#^Oj~~#aXrs!l!P!Q!l#O#P!l#U#V!l#Y#Z!l#b#c!l#f#g!l#h#i!l#i#j#|~$PR!Q![$Y!c!i$Y#T#Z$Y~$]R!Q![$f!c!i$f#T#Z$f~$iR!Q![$r!c!i$r#T#Z$r~$uR!Q![!l!c!i!l#T#Z!l~%RP;=`<%l!l~%ZO_~~%^Q!Q!R%d!R![&r~%iRV~!O!P%r!g!h&W#X#Y&W~%uP!Q![%x~%}RV~!Q![%x!g!h&W#X#Y&W~&ZR{|&d}!O&d!Q![&j~&gP!Q![&j~&oPV~!Q![&j~&wSV~!O!P%r!Q![&r!g!h&W#X#Y&W~'WQz{'^!P!Q(_~'aTOz'^z{'p{;'S'^;'S;=`(X<%lO'^~'sTO!P'^!P!Q(S!Q;'S'^;'S;=`(X<%lO'^~(XOQ~~([P;=`<%l'^~(dTP~OY(_Z](_^;'S(_;'S;=`(s<%lO(_~(vP;=`<%l(_~)OO^~~)TOa~~)YO`~~)]P#T#U)`~)cP#`#a)f~)iP#g#h)l~)oP#X#Y)r~)wOT~~)zP#i#j)}~*QP#`#a*T~*WP#`#a*Z~*`OU~~*cP#f#g*f~*iP#i#j*l~*oP#X#Y*r~*wOS~~*|OY~~+ROX~",
18+
tokenizers: [0],
19+
topRules: {"JsonText":[0,3]},
20+
tokenPrec: 0
21+
})
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// This file was generated by lezer-generator. You probably shouldn't edit it.
2+
export const
3+
LineComment = 1,
4+
BlockComment = 2,
5+
JsonText = 3,
6+
True = 4,
7+
False = 5,
8+
Null = 6,
9+
Number = 7,
10+
String = 8,
11+
Object = 11,
12+
Property = 12,
13+
PropertyName = 13,
14+
Array = 18

0 commit comments

Comments
 (0)