Skip to content

Commit 90f6f7d

Browse files
authored
feat: Add plaintext format detection (#15)
1 parent 7963171 commit 90f6f7d

4 files changed

Lines changed: 283 additions & 1 deletion

File tree

src/detect.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// ---------------------------------------------------------------------------
2+
// Format detection
3+
// ---------------------------------------------------------------------------
4+
5+
import { isMyST } from "./parseMd";
6+
7+
export type PlainbFormat = "percent" | "sphinx-gallery" | "classic" | "myst";
8+
9+
/**
10+
* Tracks quote states to determine if lines/delimiters are within strings or docstrings.
11+
*/
12+
class StringParser {
13+
private single: string | null = null;
14+
private triple: string | null = null;
15+
private tripleStart = -1;
16+
17+
isQuoted(): boolean {
18+
return this.single !== null || this.triple !== null;
19+
}
20+
21+
readLine(line: string): void {
22+
// Comment lines outside of strings do not change state.
23+
if (!this.isQuoted() && line.trimStart().startsWith("#")) {
24+
return;
25+
}
26+
this.tripleStart = -1;
27+
for (let i = 0; i < line.length; i++) {
28+
const char = line[i];
29+
// '#' outside of any string starts a comment.
30+
if (this.single === null && this.triple === null && char === "#") {
31+
break;
32+
}
33+
if (char !== '"' && char !== "'") {
34+
continue;
35+
}
36+
// Escaped quote.
37+
if (line[i - 1] === "\\") {
38+
continue;
39+
}
40+
if (this.single === char) {
41+
this.single = null;
42+
continue;
43+
}
44+
if (this.single !== null) {
45+
continue;
46+
}
47+
// Triple quote start or end.
48+
if (i >= this.tripleStart + 3 && line.slice(i - 2, i + 1) === char.repeat(3)) {
49+
if (this.triple === char) {
50+
this.triple = null;
51+
this.tripleStart = i;
52+
continue;
53+
}
54+
if (this.triple !== null) {
55+
continue;
56+
}
57+
this.triple = char;
58+
this.tripleStart = i;
59+
continue;
60+
}
61+
if (this.triple !== null) {
62+
continue;
63+
}
64+
// Single/double quoted string start.
65+
this.single = char;
66+
}
67+
// Single-line quotes do not carry over to the next line in Python.
68+
this.single = null;
69+
}
70+
}
71+
72+
const DOUBLE_PERCENT_RE = /^#\s*%%/;
73+
const TWENTY_HASH_RE = /^#( ?)#{19,}\s*$/;
74+
75+
/** Check if the file starts with a module docstring, ignoring front matter. */
76+
function hasLeadingDocstring(lines: string[]): boolean {
77+
let i = 0;
78+
// Skip commented YAML front matter.
79+
if (lines[0]?.trim() === "# ---") {
80+
i = 1;
81+
while (i < lines.length && lines[i].trim() !== "# ---") {
82+
i++;
83+
}
84+
i++; // skip the closing delimiter
85+
}
86+
// A module docstring may only be preceded by blank lines and comments.
87+
while (i < lines.length && (lines[i].trim() === "" || lines[i].trimStart().startsWith("#"))) {
88+
i++;
89+
}
90+
return /^[rbuf]*("""|''')/i.test((lines[i] ?? "").trimStart());
91+
}
92+
93+
/** Detect whether a Python script uses percent or Sphinx Gallery format. */
94+
export function detectPy(text: string): "percent" | "sphinx-gallery" {
95+
const lines = text.replace(/\r\n/g, "\n").split("\n");
96+
const parser = new StringParser();
97+
let doublePercent = 0;
98+
let twentyHash = 0;
99+
let hasPercentMd = false;
100+
101+
for (const line of lines) {
102+
parser.readLine(line);
103+
if (parser.isQuoted()) {
104+
continue;
105+
}
106+
if (DOUBLE_PERCENT_RE.test(line)) {
107+
doublePercent++;
108+
if (/# %%\s*\[(markdown|md|raw)\]/i.test(line)) {
109+
hasPercentMd = true;
110+
}
111+
}
112+
if (TWENTY_HASH_RE.test(line)) {
113+
twentyHash++;
114+
}
115+
}
116+
117+
// A Sphinx Gallery script starts with a module docstring, and separates
118+
// cells using either twenty hashes or `# %%` without percent-format cell tags.
119+
if (hasLeadingDocstring(lines) && !hasPercentMd) {
120+
return "sphinx-gallery";
121+
}
122+
123+
if (doublePercent >= 1) {
124+
return "percent";
125+
}
126+
if (twentyHash >= 2) {
127+
return "sphinx-gallery";
128+
}
129+
return "percent";
130+
}
131+
132+
/**
133+
* Detect the plainb format of a file from its text and extension.
134+
*/
135+
export function detectFormat(text: string, ext: string): PlainbFormat {
136+
const normExt = ext.toLowerCase().replace(/^\./, "");
137+
if (normExt === "md" || normExt === "markdown") {
138+
return isMyST(text) ? "myst" : "classic";
139+
}
140+
return detectPy(text);
141+
}

src/index.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,84 @@ export * from "./parseClassicMd";
44
export * from "./parseMystMd";
55
export * from "./parseSphinxGallery";
66
export * from "./notebook";
7+
export * from "./detect";
78
export * from "./toPy";
89
export * from "./toClassicMd";
910
export * from "./toMystMd";
1011
export * from "./toSphinxGallery";
1112

1213
import { parsePy } from "./parsePy";
1314
import { parseMd } from "./parseMd";
15+
import { parseClassicMd } from "./parseClassicMd";
16+
import { parseMystMd } from "./parseMystMd";
1417
import { parseSphinxGallery } from "./parseSphinxGallery";
1518
import { toPy } from "./toPy";
19+
import { toClassicMd } from "./toClassicMd";
1620
import { toMystMd } from "./toMystMd";
1721
import { toSphinxGallery } from "./toSphinxGallery";
22+
import type { PlainbFormat } from "./detect";
1823
import type { Notebook } from "./notebook";
1924

25+
/**
26+
* Parse a file by explicitly providing the format.
27+
*
28+
* @param text - the file contents
29+
* @param format - the format of the file, "py", "md", or "sphinx-gallery"
30+
*/
2031
export function parse(text: string, format: "py" | "md" | "sphinx-gallery"): Notebook {
2132
if (format === "py") return parsePy(text);
2233
if (format === "md") return parseMd(text);
2334
if (format === "sphinx-gallery") return parseSphinxGallery(text);
2435
throw new Error(`Unknown format: "${format}". Expected "py", "md", or "sphinx-gallery".`);
2536
}
2637

38+
/**
39+
* Serialize a notebook to a file by explicitly providing the format.
40+
*
41+
* @param notebook - the notebook to serialize
42+
* @param format - the format to serialize to, "py", "md", or "sphinx-gallery"
43+
*/
2744
export function serialize(notebook: Notebook, format: "py" | "md" | "sphinx-gallery"): string {
2845
if (format === "py") return toPy(notebook);
2946
if (format === "md") return toMystMd(notebook);
3047
if (format === "sphinx-gallery") return toSphinxGallery(notebook);
3148
throw new Error(`Unknown format: "${format}". Expected "py", "md", or "sphinx-gallery".`);
3249
}
50+
51+
/**
52+
* Parse a file given its detected {@link PlainbFormat}.
53+
*
54+
* @param text - the file contents
55+
* @param format - the format returned by {@link detectFormat}
56+
*/
57+
export function parseFormat(text: string, format: PlainbFormat): Notebook {
58+
switch (format) {
59+
case "percent":
60+
return parsePy(text);
61+
case "sphinx-gallery":
62+
return parseSphinxGallery(text);
63+
case "myst":
64+
return parseMystMd(text);
65+
case "classic":
66+
return parseClassicMd(text);
67+
}
68+
}
69+
70+
/**
71+
* Serialize a notebook to a given {@link PlainbFormat}.
72+
*
73+
* @param notebook - the notebook to serialize
74+
* @param format - the target format
75+
*/
76+
export function serializeFormat(notebook: Notebook, format: PlainbFormat): string {
77+
switch (format) {
78+
case "percent":
79+
return toPy(notebook);
80+
case "sphinx-gallery":
81+
return toSphinxGallery(notebook);
82+
case "myst":
83+
return toMystMd(notebook);
84+
case "classic":
85+
return toClassicMd(notebook);
86+
}
87+
}

src/parseMd.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { parseMystMd } from "./parseMystMd";
33
import type { Notebook } from "./notebook";
44

55
/** Detect MyST notebook format by scanning for {code-cell}/{raw-cell} directives or +++ breaks. */
6-
function isMyST(text: string): boolean {
6+
export function isMyST(text: string): boolean {
77
const lines = text.split("\n");
88
const limit = Math.min(lines.length, 100);
99
for (let i = 0; i < limit; i++) {

test/detect.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { test, describe } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { detectFormat, detectPy } from "../src/detect.js";
4+
5+
// ---------------------------------------------------------------------------
6+
// Python scripts
7+
// ---------------------------------------------------------------------------
8+
9+
describe("detectPy", () => {
10+
test("`# %%` delimiter → percent", () => {
11+
assert.equal(detectPy("# %%\nx = 1\n# %%\ny = 2"), "percent");
12+
});
13+
14+
test("`#%%` without space → percent", () => {
15+
assert.equal(detectPy("#%%\nx = 1"), "percent");
16+
});
17+
18+
test("two 20-hash runs → sphinx-gallery", () => {
19+
const text = "####################\nx = 1\n####################\ny = 2";
20+
assert.equal(detectPy(text), "sphinx-gallery");
21+
});
22+
23+
test("`# %%` wins over hash runs (percent precedence)", () => {
24+
const text = "####################\n# %%\nx = 1\n####################";
25+
assert.equal(detectPy(text), "percent");
26+
});
27+
28+
test("leading module docstring, no delimiters → sphinx-gallery", () => {
29+
assert.equal(detectPy('"""Module doc."""\nimport os\nx = 1'), "sphinx-gallery");
30+
});
31+
32+
test("r-prefixed docstring → sphinx-gallery", () => {
33+
assert.equal(detectPy('r"""\nTitle\n=====\n"""\nx = 1'), "sphinx-gallery");
34+
});
35+
36+
test("plain script, no docstring, no delimiters → percent", () => {
37+
assert.equal(detectPy("import os\nx = 1\nprint(x)"), "percent");
38+
});
39+
40+
test("`# %%` inside a docstring is not counted", () => {
41+
// The only `# %%` lives inside the docstring; file should fall back to the
42+
// leading-docstring rule → sphinx-gallery, not percent.
43+
const text = '"""\nExample:\n# %%\n"""\nimport os';
44+
assert.equal(detectPy(text), "sphinx-gallery");
45+
});
46+
47+
test("a single hash run is not enough for sphinx", () => {
48+
assert.equal(detectPy("####################\nimport os\nx = 1"), "percent");
49+
});
50+
51+
test("leading docstring + `# %%` cell separators → sphinx-gallery", () => {
52+
const text = '"""\nLeading docstring\n"""\n# %%\nx = 1\n# %%\ny = 2';
53+
assert.equal(detectPy(text), "sphinx-gallery");
54+
});
55+
56+
test("leading docstring + `# %% [markdown]` → percent", () => {
57+
const text = '"""\nLeading docstring\n"""\n# %% [markdown]\n# Some text\n# %%\nx = 1';
58+
assert.equal(detectPy(text), "percent");
59+
});
60+
});
61+
62+
// ---------------------------------------------------------------------------
63+
// Extension dispatch
64+
// ---------------------------------------------------------------------------
65+
66+
describe("detectFormat", () => {
67+
test(".md with {code-cell} → myst", () => {
68+
assert.equal(detectFormat("```{code-cell}\nx = 1\n```", ".md"), "myst");
69+
});
70+
71+
test(".md with +++ → myst", () => {
72+
assert.equal(detectFormat("First.\n+++\nSecond.", ".md"), "myst");
73+
});
74+
75+
test(".md plain prose → classic", () => {
76+
assert.equal(detectFormat("# Title\n\n```python\nx = 1\n```", ".md"), "classic");
77+
});
78+
79+
test("extension without a leading dot is accepted", () => {
80+
assert.equal(detectFormat("# %%\nx = 1", "py"), "percent");
81+
});
82+
83+
test(".py routes to detectPy", () => {
84+
assert.equal(detectFormat("# %%\nx = 1", ".py"), "percent");
85+
});
86+
});

0 commit comments

Comments
 (0)