Skip to content

Commit 6b5ffa4

Browse files
committed
Add file parser
1 parent b692e5e commit 6b5ffa4

File tree

3 files changed

+276
-2
lines changed

3 files changed

+276
-2
lines changed

lib/package.json

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,16 @@
3939
"@types/picomatch": "^4.0.2",
4040
"@vitest/coverage-v8": "^3.2.4",
4141
"esbuild-plugin-rdi": "^0.0.0",
42+
"fast-xml-parser": "^5.2.5",
43+
"json5": "^2.2.3",
4244
"micromatch": "^4.0.8",
4345
"picomatch": "^4.0.3",
46+
"toml": "^3.0.0",
4447
"tsup": "^8.5.0",
4548
"typescript": "^5.9.2",
4649
"vite-tsconfig-paths": "^5.1.4",
47-
"vitest": "^3.2.4"
50+
"vitest": "^3.2.4",
51+
"yaml": "^2.8.1"
4852
},
4953
"funding": [
5054
{
@@ -70,5 +74,33 @@
7074
"tooling",
7175
"react18-tools",
7276
"mayank1513"
73-
]
77+
],
78+
"peerDependencies": {
79+
"fast-xml-parser": "^5.2.5",
80+
"json5": "^2.2.3",
81+
"micromatch": "^4.0.8",
82+
"picomatch": "^4.0.3",
83+
"toml": "^3.0.0",
84+
"yaml": "^2.8.1"
85+
},
86+
"peerDependenciesMeta": {
87+
"fast-xml-parser": {
88+
"optional": true
89+
},
90+
"json5": {
91+
"optional": true
92+
},
93+
"micromatch": {
94+
"optional": true
95+
},
96+
"picomatch": {
97+
"optional": true
98+
},
99+
"toml": {
100+
"optional": true
101+
},
102+
"yaml": {
103+
"optional": true
104+
}
105+
}
74106
}

lib/src/file-parser.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/**
2+
* Represents a parsed conflict from a file with `ours` and `theirs` versions.
3+
*
4+
* @template T - The type of the parsed content.
5+
*/
6+
export interface ParsedConflict<T = unknown> {
7+
/** Parsed content from the "ours" side of the conflict. */
8+
ours: T;
9+
/** Parsed content from the "theirs" side of the conflict. */
10+
theirs: T;
11+
/** Format used to parse the content (`json`, `yaml`, `toml`, `xml`, or `custom`). */
12+
format: string;
13+
}
14+
15+
/** A parser function that takes a raw string and returns parsed content. */
16+
export type Parser = (input: string) => unknown;
17+
18+
/** Built-in parser identifiers or a custom parser function. */
19+
export type SupportedParsers = "json" | "json5" | "yaml" | "toml" | "xml" | Parser;
20+
21+
/**
22+
* Options for parsing conflicted content.
23+
*/
24+
export interface ParseConflictOptions {
25+
/**
26+
* Parsers to attempt, in order:
27+
* - A single parser (`"json"`, `"yaml"`, custom function, etc.).
28+
* - An array of parsers (e.g. `["yaml", "json5"]`).
29+
*
30+
* Defaults to `"json"`.
31+
*/
32+
parsers?: "auto" | SupportedParsers | SupportedParsers[];
33+
34+
/**
35+
* Optional filename hint to prioritize parser choice.
36+
* Example:
37+
* - `config.yaml` → try `yaml` first.
38+
* - `data.toml` → try `toml` first.
39+
*
40+
* If extension is unknown, falls back to `parsers` or `"json"`.
41+
*/
42+
filename?: string;
43+
}
44+
45+
/**
46+
* Parses a conflicted file's content into separate `ours` and `theirs` objects.
47+
*
48+
* - Preserves non-conflicted lines in both versions.
49+
* - Supports JSON, JSON5, YAML, TOML, and XML.
50+
* - Lazy-loads optional peer dependencies (`json5`, `yaml`, `toml`, `fast-xml-parser`).
51+
* - Parser order is determined by:
52+
* 1. `filename` extension hint (if provided).
53+
* 2. Explicit `parsers` option.
54+
* 3. Default `"json"`.
55+
*
56+
* @template T - Expected type of parsed content.
57+
* @param content - Raw file content containing conflict markers.
58+
* @param options - Parsing options (parsers + filename hint).
59+
* @returns Parsed conflict with both sides and detected format.
60+
* @throws If parsing fails or conflict markers are invalid.
61+
*/
62+
export const parseConflictContent = async <T = unknown>(
63+
content: string,
64+
options: ParseConflictOptions = {},
65+
): Promise<ParsedConflict<T>> => {
66+
const lines = content.split("\n");
67+
const oursLines: string[] = [];
68+
const theirsLines: string[] = [];
69+
70+
enum State {
71+
Normal,
72+
InOurs,
73+
InTheirs,
74+
}
75+
let state = State.Normal;
76+
77+
for (const line of lines) {
78+
if (line.startsWith("<<<<<<<")) {
79+
state = State.InOurs;
80+
continue;
81+
} else if (line.startsWith("=======")) {
82+
if (state === State.InOurs) state = State.InTheirs;
83+
continue;
84+
} else if (line.startsWith(">>>>>>>")) {
85+
if (state === State.InTheirs) state = State.Normal;
86+
continue;
87+
}
88+
89+
switch (state) {
90+
case State.Normal:
91+
oursLines.push(line);
92+
theirsLines.push(line);
93+
break;
94+
case State.InOurs:
95+
oursLines.push(line);
96+
break;
97+
case State.InTheirs:
98+
theirsLines.push(line);
99+
break;
100+
}
101+
}
102+
103+
const oursRaw = oursLines.join("\n");
104+
const theirsRaw = theirsLines.join("\n");
105+
106+
if (!oursRaw || !theirsRaw) {
107+
throw new Error("Conflict parsing resulted in empty content.");
108+
}
109+
110+
// normalize parser list
111+
const parsers = normalizeParsers(options);
112+
113+
const [oursParsed, format] = await runParser(oursRaw, parsers);
114+
const [theirsParsed] = await runParser(theirsRaw, [format]);
115+
116+
return {
117+
ours: oursParsed as T,
118+
theirs: theirsParsed as T,
119+
format: typeof format === "function" ? "custom" : format,
120+
};
121+
};
122+
123+
/** Normalize parsers based on filename + options. */
124+
const normalizeParsers = (options: ParseConflictOptions): SupportedParsers[] => {
125+
if (options.parsers) {
126+
return Array.isArray(options.parsers)
127+
? options.parsers
128+
: options.parsers === "auto"
129+
? ["json", "json5", "yaml", "toml", "xml"]
130+
: [options.parsers];
131+
}
132+
133+
if (options.filename) {
134+
const ext = options.filename.split(".").pop()?.toLowerCase();
135+
switch (ext) {
136+
case "json":
137+
return ["json"];
138+
case "json5":
139+
return ["json5"];
140+
case "yaml":
141+
case "yml":
142+
return ["yaml"];
143+
case "toml":
144+
return ["toml"];
145+
case "xml":
146+
return ["xml"];
147+
}
148+
}
149+
150+
return ["json"]; // default
151+
};
152+
153+
/** Internal helper to try parsers in order. */
154+
const runParser = async (
155+
raw: string,
156+
parsers: SupportedParsers[],
157+
): Promise<[unknown, SupportedParsers]> => {
158+
for (const parser of parsers) {
159+
try {
160+
if (typeof parser === "function") return [parser(raw), parser];
161+
return [await parseFormat(parser, raw), parser];
162+
} catch (err) {
163+
console.debug(`Parser ${typeof parser === "function" ? "custom" : parser} failed:`, err);
164+
}
165+
}
166+
throw new Error(
167+
`Failed to parse content. Tried parsers: ${parsers.map(p => (typeof p === "string" ? p : "custom")).join(", ")}`,
168+
);
169+
};
170+
171+
/** Internal parser dispatcher for supported formats. */
172+
const parseFormat = async (
173+
parser: "json" | "json5" | "yaml" | "toml" | "xml",
174+
raw: string,
175+
): Promise<unknown> => {
176+
switch (parser) {
177+
case "json":
178+
return JSON.parse(raw);
179+
case "json5": {
180+
try {
181+
const { parse } = await import("json5");
182+
return parse(raw);
183+
} catch {
184+
throw new Error("json5 parser not installed. Please install as peer dependency.");
185+
}
186+
}
187+
case "yaml": {
188+
try {
189+
const { parse } = await import("yaml");
190+
return parse(raw);
191+
} catch {
192+
throw new Error("yaml parser not installed. Please install as peer dependency.");
193+
}
194+
}
195+
case "toml": {
196+
try {
197+
const { parse } = await import("toml");
198+
return parse(raw);
199+
} catch {
200+
throw new Error("toml parser not installed. Please install as peer dependency.");
201+
}
202+
}
203+
case "xml": {
204+
try {
205+
const { XMLParser } = await import("fast-xml-parser");
206+
return new XMLParser().parse(raw);
207+
} catch {
208+
throw new Error("fast-xml-parser not installed. Please install as peer dependency.");
209+
}
210+
}
211+
}
212+
};

pnpm-lock.yaml

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)