Skip to content

Commit c6afb71

Browse files
committed
☕ Generate good markdown from help
1 parent 66ccb79 commit c6afb71

File tree

8 files changed

+1009
-64
lines changed

8 files changed

+1009
-64
lines changed

scripts/gen-function/format.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,7 @@ const translate: Record<string, string> = {
2020

2121
function formatDocs(docs: string): string[] {
2222
const lines = docs.replaceAll(/\*\//g, "* /").split("\n");
23-
const leading =
24-
lines.map((v) => v.match(/^\s*/)![0]).reduce((a, v) =>
25-
a.length < v.length ? a : v
26-
).length;
27-
const normalizedLines = lines.map((v) => ` * ${v.substring(leading)}`);
23+
const normalizedLines = lines.map((v) => ` * ${v}`.trimEnd());
2824
return ["/**", ...normalizedLines, " */"];
2925
}
3026

scripts/gen-function/parse.ts

Lines changed: 49 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Definition, Variant } from "./types.ts";
2+
import { createMarkdownFromHelp } from "../markdown.ts";
23
import { Counter, regexIndexOf } from "../utils.ts";
34

45
/**
@@ -64,48 +65,62 @@ function extractBlock(content: string, index: number): {
6465
* A function definition block is constrcuted with following parts
6566
*
6667
* ```text
67-
* cursor({lnum}, {col} [, {off}]) <- variant
68-
* cursor({list}) <- variant
68+
* cursor({lnum}, {col} [, {off}]) <- variant[0]
69+
* cursor({list}) <- variant[1]
6970
* Positions the cursor at the column ... <- document
7071
* line {lnum}. The first column is one... <- document
7172
* ```
7273
*
74+
* {variant} may be two lines.
75+
*
76+
* ```text
77+
* searchpairpos({start}, {middle}, {end} [, {flags} [, {skip}
78+
* [, {stopline} [, {timeout}]]]])
79+
* Same as |searchpair()|, but returns a ...
80+
* ```
81+
*
82+
* {document} may start on the same line as {variant}.
83+
*
84+
* ```text
85+
* argidx() The result is the current index in the ...
86+
* the first file. argc() - 1 is the last...
87+
* ```
88+
*
7389
* This function parse content like above and return `Definition`.
7490
*/
75-
function parseBlock(fn: string, body: string): Definition {
76-
// Remove tags
77-
body = body.replaceAll(/\*\S+?\*/g, "");
78-
// Remove trailing spaces
79-
body = body.split("\n").map((v) => v.trimEnd()).join("\n");
91+
function parseBlock(fn: string, body: string): Definition | undefined {
92+
// Separate vars/docs blocks
93+
const reTags = /(?:[ \t]+\*[^*\s]+\*)+[ \t]*$/.source;
94+
const reArgs = /\([^()]*(?:\n[ \t][^()]*)?\)/.source;
95+
const reVariant = `^${fn}${reArgs}(?:${reTags})?`;
96+
const reVariants = `^(?:${reVariant}\\n)*${reVariant}`;
97+
const reVarsDocs = `(?<vars>${reVariants})(?<docs>.*)`;
98+
const m1 = body.match(new RegExp(reVarsDocs, "ms"));
99+
if (!m1) {
100+
return;
101+
}
80102

81-
// Remove '\n' in {variant} to make {variant} single line (ex. `searchpairpos`)
82-
body = body.replaceAll(new RegExp(`^(${fn}\\([^)]*?)\\n\\t*`, "gm"), "$1");
83-
// Append ')' for an invalid {variant}. (ex. `win_id2tabwin` in Neovim)
84-
body = body.replaceAll(new RegExp(`^(${fn}\\([^)]*?)\\t+`, "gm"), "$1)\t");
85-
// Insert '\n' between {variant} and {document} (ex. `argidx`)
86-
body = body.replaceAll(new RegExp(`^(${fn}\\(.*?\\))\\t`, "gm"), "$1\n\t\t");
103+
// Extract vars block
104+
const varsBlock = m1.groups!.vars
105+
// Remove tags after {variant} (ex. `cursor()`)
106+
.replaceAll(new RegExp(reTags, "gm"), "")
107+
// Make {variant} to single line (ex. `searchpairpos()`)
108+
.replaceAll(/\n[ \t]+/g, "");
87109

88-
// Remove leading '>' or trailing '<' which is used to define code block in help
89-
body = body.replaceAll(/\n<|>\n/g, "\n");
110+
// Extract docs block
111+
const docsBlock = m1.groups!.docs
112+
// Restore indent that {document} continues on {variant} line (ex. `argidx`)
113+
.replace(
114+
/^(?=[ \t]+\S)/,
115+
() => varsBlock.split("\n").at(-1)!.replaceAll(/[^\t]/g, " "),
116+
);
90117

91-
// Split body into vars/docs
92-
const vars: Variant[] = [];
93-
const docs: string[] = [];
94-
body.split("\n").forEach((line) => {
95-
if (/^\s/.test(line)) {
96-
docs.push(line.replace(/^\t\t/, ""));
97-
} else {
98-
const variant = parseVariant(line);
99-
if (variant) {
100-
vars.push(variant);
101-
}
102-
}
103-
});
104-
return {
105-
fn,
106-
docs: docs.join("\n"),
107-
vars,
108-
};
118+
const vars = varsBlock.split("\n")
119+
.map(parseVariant)
120+
.filter(<T>(x: T): x is NonNullable<T> => !!x);
121+
const docs = createMarkdownFromHelp(docsBlock);
122+
123+
return { fn, docs, vars };
109124
}
110125

111126
/**
@@ -126,7 +141,7 @@ function parseBlock(fn: string, body: string): Definition {
126141
*/
127142
function parseVariant(variant: string): Variant | undefined {
128143
// Extract {args} part from {variant}
129-
const m = variant.match(new RegExp(`^\\w+\\(\(.+?\)\\)`));
144+
const m = variant.match(/^\w+\((.+?)\)/);
130145
if (!m) {
131146
// The {variant} does not have {args}, probabliy it's not variant (ex. `strstr`)
132147
return undefined;

scripts/gen-option/format.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,7 @@ function defaultValue(type: OptionType): string {
2626

2727
function formatDocs(docs: string): string[] {
2828
const lines = docs.replaceAll(/\*\//g, "* /").split("\n");
29-
const leading =
30-
lines.map((v) => v.match(/^\s*/)![0]).reduce((a, v) =>
31-
a.length < v.length ? a : v
32-
).length;
33-
const normalizedLines = lines.map((v) => ` * ${v.substring(leading)}`);
29+
const normalizedLines = lines.map((v) => ` * ${v}`.trimEnd());
3430
return ["/**", ...normalizedLines, " */"];
3531
}
3632

scripts/gen-option/parse.ts

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { isOptionType, type Option, type OptionType } from "./types.ts";
2-
import { regexIndexOf } from "../utils.ts";
2+
import { createMarkdownFromHelp } from "../markdown.ts";
3+
import { regexIndexOf, trimLines } from "../utils.ts";
34

45
const fallbackTypes: Record<string, OptionType> = {
56
"encoding": "string", // not defined type in nvim 0.8.0
@@ -15,6 +16,8 @@ const fallbackTypes: Record<string, OptionType> = {
1516
* *'aleph'* *'al'* *aleph* *Aleph*
1617
* 'aleph' 'al' number (default 224)
1718
* global
19+
* {only available when compiled with the |+rightleft|
20+
* feature}
1821
* The ASCII code for the first letter of the Hebrew alphabet. The
1922
* routine that maps the keyboard in Hebrew mode, both in Insert mode
2023
* ...
@@ -76,30 +79,62 @@ function extractBlock(content: string, index: number): {
7679
return { block, start: s, end: s + block.length };
7780
}
7881

82+
/**
83+
* Parse option definition block.
84+
*
85+
* A option definition block is constrcuted with following parts
86+
*
87+
* - {name} : Required.
88+
* - {type} : Required. But some have fallbacks.
89+
* - {scope} : Optional. If not present, assume "global".
90+
* - {defaults} : Optional. Appended to {document}.
91+
* - {attention} : Optional. Appended to {document}.
92+
* - {document} : Optional.
93+
*
94+
* ```text
95+
* name type defaults
96+
* ~~~~~ ~~~~~~ ~~~~~~~~~~~~~
97+
* 'aleph' 'al' number (default 224) *E123*
98+
* global <- scope
99+
* {only available when compiled ... <- attention
100+
* feature} :
101+
* The ASCII code for the first letter of the ... <- document
102+
* routine that maps the keyboard in Hebrew mode ... :
103+
* ```
104+
*/
79105
function parseBlock(name: string, body: string): Option | undefined {
80-
const m1 = body.match(
81-
new RegExp(`^'${name}'(?:\\s+'\\w+')*\\s+(\\w+)\\s`, "m"),
82-
);
83-
const type = m1?.[1] ?? fallbackTypes[name];
84-
if (!isOptionType(type)) {
106+
// Extract definition line
107+
const reTags = /(?:[ \t]+\*[^*\s]+\*)+[ \t]*$/.source;
108+
const reShortNames = /(?:[ \t]+'\w+')*/.source;
109+
const reType = /[ \t]+(?<type>\w+)/.source;
110+
const reDefaults = /[ \t]+(?<defaults>\(.*?(?:\n\t{3,}[ \t].*?)*?\))/.source;
111+
const reDefinition =
112+
`^'${name}'${reShortNames}(?:${reType}(?:${reDefaults})?)?(?:${reTags})?$`;
113+
const m1 = body.match(new RegExp(reDefinition, "dm"));
114+
const type = m1?.groups!.type ?? fallbackTypes[name];
115+
if (!m1 || !isOptionType(type)) {
116+
// {name} not found, or {type} is invalid
85117
return;
86118
}
119+
const defaults = m1.groups!.defaults?.replaceAll(/^\s+/gm, " ").trim();
120+
body = trimLines(body.substring(m1.indices![0][1])) + "\n";
121+
122+
// Extract {scope}
123+
const m2 = body.match(/^\t{3,}(global or local|global|local)(?:[ \t].*)?\n/d);
124+
const scope = (
125+
m2?.[1].split(" or ") ?? ["global"]
126+
) as Array<"global" | "local">;
127+
body = trimLines(body.substring(m2?.indices![0][1] ?? 0)) + "\n";
87128

88-
const m2 = body.match(/\n\t{3,}(global or local|global|local)(?:\s|$)/);
89-
const scope = (m2?.[1].split(" or ") ?? ["global"]) as Array<
90-
"global" | "local"
91-
>;
129+
// Extract {attention}
130+
const m3 = body.match(/^\t{3,}(\{(?:Vi[: ]|not|only)[^{}]*\})\s*?\n/d);
131+
const attention = m3?.[1].replaceAll(/\s+/g, " ").trim();
132+
body = trimLines(body.substring(m3?.indices![0][1] ?? 0)) + "\n";
92133

93-
const s = regexIndexOf(body, /\n|$/, (m2?.index ?? m1?.index ?? 0) + 1);
94-
body = body.substring(s);
134+
// Append {defaults} and {attention} to {document}
135+
body += `\n\n${defaults ?? ""}\n\n${attention ?? ""}`;
95136

96-
// Remove tags
97-
body = body.replaceAll(/\*\S+?\*/g, "");
98-
// Remove trailing spaces
99-
body = body.split("\n").map((v) => v.trimEnd()).join("\n");
100-
// Remove indent
101-
body = body.split("\n").map((v) => v.replace(/^\t/, "")).join("\n");
137+
const docs = createMarkdownFromHelp(body);
102138

103-
const docs = body;
104139
return { name, type, scope, docs };
105140
}

scripts/markdown.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { removeIndentSpaces, replaceTabToSpaces, trimLines } from "./utils.ts";
2+
3+
/** Create markdown from Vim help. */
4+
export function createMarkdownFromHelp(body: string): string {
5+
// Replace TABs to spaces
6+
body = trimLines(body).split("\n").map(
7+
(v) => replaceTabToSpaces(v.trimEnd()),
8+
).join("\n");
9+
const firstlineIndent = body.match(/^ */)![0];
10+
11+
// Reindent and add blank lines around to code block >...<
12+
const codeBlockIndent = " ";
13+
let lastIndent = firstlineIndent;
14+
body = body.replaceAll(
15+
/(?<normal>.*?)[\n ]>\n(?<code>.*?)(?:\n(?=<)|$)|(?<rest>.*)/gs,
16+
(_, normal: string, code: string, rest: string) => {
17+
if (rest !== undefined) {
18+
return formatNormalBlock(rest);
19+
}
20+
21+
// Gets the previous line indent, or keeps the last value if there is
22+
// only one line between code blocks
23+
const prevLine = normal.trimEnd().split("\n").at(-1)!;
24+
lastIndent = prevLine.match(/^ +/)?.[0] ?? lastIndent;
25+
26+
const normalFormatted = formatNormalBlock(normal);
27+
const codeFormatted = removeIndentSpaces(code.split("\n"))
28+
.map((v) => `${lastIndent}${codeBlockIndent}${v}`.trimEnd())
29+
.join("\n");
30+
return `${normalFormatted}\n\n${codeFormatted}\n\n`;
31+
},
32+
);
33+
34+
// Trim trailing spaces
35+
body = body.replaceAll(/ +$/gm, "");
36+
37+
// Merge multiple blank lines to one
38+
body = trimLines(body.replaceAll(/\n{3,}/g, "\n\n"));
39+
40+
// Remove entire indentation with the first line indentation
41+
body = body.replaceAll(new RegExp(`^${firstlineIndent}`, "gm"), "");
42+
43+
return body;
44+
}
45+
46+
function formatNormalBlock(body: string): string {
47+
// Replace previous help code block delimiter
48+
body = body.replace(/^</, " ");
49+
50+
// Remove tags *...* without inline sentence
51+
body = body.replaceAll(/(?:[ \t]+\*[^*\s]+\*)+[ \t]*$/gm, "");
52+
53+
// Remove header mark '... ~'
54+
body = body.replaceAll(/ ~$/gm, "");
55+
56+
// Replace nvim list marker at beginning of line
57+
body = body.replaceAll(/(?<=^ *)\u2022 /gm, "- ");
58+
59+
// Replace not-Vi attention {not...} to emphasis *not...*
60+
body = body.replaceAll(
61+
/\{((?:Vi[:\s]|not|only)[^{}]*)\}/g,
62+
(_, v: string) => v.includes("*") ? `_${v}_` : `*${v}*`,
63+
);
64+
65+
// Replace quoted-string that contains `~< to code block ``"..`.."``
66+
body = body.replaceAll(
67+
/(?<=^|[\s(=])"(?:[^"\\]|\\.)*?"(?=[\s),.;:]|$)/g,
68+
(v) => v.match(/[`~]|<([^"']|$)/) ? createInlineCodeBlock(v) : v,
69+
);
70+
body = body.replaceAll(/"<"|'<'/g, "`$&`");
71+
72+
// Replace special cases to code block `` a` ``
73+
body = body
74+
.replaceAll(
75+
/(?<= )(a`|[`']\{A-Z0-9\})(?=[ ,])/g,
76+
(_, v) => createInlineCodeBlock(v),
77+
);
78+
79+
// Replace link |...| to code block `...`
80+
// NOTE: If link contains backticks it cannot be replaced. So add the special case above.
81+
body = replaceWithoutCodeBlock(body, (s) =>
82+
s.replaceAll(
83+
/(?<=^|[\s(=])\|+([!#-)+-_a-{}-~]+?)\|+/g, // ignored 0x20 and "*`|
84+
(_, v: string) => createInlineCodeBlock(v),
85+
));
86+
87+
// Replace # at beginning of line to code block `#`
88+
body = replaceWithoutCodeBlock(body, (s) =>
89+
s.replaceAll(
90+
/(?<=^\s*)#\S*/gm,
91+
(v) => createInlineCodeBlock(v),
92+
));
93+
94+
// Replace keycode <...> to code block `<...>`
95+
// Replace with plural <...>s to `<...>`s
96+
body = replaceWithoutCodeBlock(body, (s) =>
97+
s.replaceAll(
98+
/(?<=^|[\s(=])((?:<[^\s<>]*>)+)(s?)(?=[\s),.;:]|$)/g,
99+
(_, v: string, s: string) => createInlineCodeBlock(v) + (s ?? ""),
100+
));
101+
102+
// Replace string that contains ~ to code block `..~..`
103+
body = replaceWithoutCodeBlock(body, (s) =>
104+
s.replaceAll(
105+
/(?<=^|[\s(=])(?![(=])\S*?~\S*(?<=[^),.;:])(?=[\s),.;:]|$)/g,
106+
(v) => createInlineCodeBlock(v),
107+
));
108+
109+
// Replace args {...} to strong emphasis **{...}**
110+
body = replaceWithoutCodeBlock(body, (s) =>
111+
s.replaceAll(
112+
/\{[-a-zA-Z0-9'"*+/:%#=\[\]<>.,]+?\}/g,
113+
"**$&**",
114+
));
115+
116+
return body;
117+
}
118+
119+
function replaceWithoutCodeBlock(
120+
body: string,
121+
replacer: (s: string) => string,
122+
): string {
123+
return body.replaceAll(
124+
/(?<normal>.*?)(?<code>``.*?``|`.*?`)|(?<rest>.*)/gs,
125+
(_, normal: string, code: string, rest: string) =>
126+
rest !== undefined ? replacer(rest) : `${replacer(normal)}${code}`,
127+
);
128+
}
129+
130+
function createInlineCodeBlock(v: string): string {
131+
// With double quoted ``...``
132+
if (v.includes("`")) {
133+
const prefix = v.startsWith("`") ? " " : "";
134+
const suffix = v.endsWith("`") ? " " : "";
135+
return `\`\`${prefix}${v}${suffix}\`\``;
136+
}
137+
// With single quoted `...`
138+
return `\`${v}\``;
139+
}

0 commit comments

Comments
 (0)