feat: add native Rust GSD file parser for .gsd/ directory parsing
Implements a Rust napi-rs module that parses YAML-like frontmatter, markdown sections, and roadmap structures from .gsd/ files. Provides parseFrontmatter, extractSection, extractAllSections, batchParseGsdFiles, and parseRoadmapFile functions exposed via @gsd/native. The JS parsers in files.ts fall back transparently when the native module is unavailable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b37819e30a
commit
064c4cfc1a
9 changed files with 1379 additions and 7 deletions
7
native/Cargo.lock
generated
7
native/Cargo.lock
generated
|
|
@ -543,7 +543,6 @@ dependencies = [
|
|||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"similar",
|
||||
"smallvec",
|
||||
"syntect",
|
||||
"unicode-segmentation",
|
||||
|
|
@ -1207,12 +1206,6 @@ version = "0.3.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
|
|
|
|||
1053
native/crates/engine/src/gsd_parser.rs
Normal file
1053
native/crates/engine/src/gsd_parser.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -21,4 +21,5 @@ mod ps;
|
|||
mod task;
|
||||
mod text;
|
||||
mod ttsr;
|
||||
mod gsd_parser;
|
||||
mod image;
|
||||
|
|
|
|||
98
packages/native/src/gsd-parser/index.ts
Normal file
98
packages/native/src/gsd-parser/index.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* GSD file parser — native Rust implementation.
|
||||
*
|
||||
* Parses `.gsd/` directory markdown files containing YAML-like frontmatter
|
||||
* and structured sections. Replaces the JS regex-based parser for
|
||||
* performance-critical batch operations.
|
||||
*/
|
||||
|
||||
import { native } from "../native.js";
|
||||
import type {
|
||||
BatchParseResult,
|
||||
FrontmatterResult,
|
||||
NativeRoadmap,
|
||||
SectionResult,
|
||||
} from "./types.js";
|
||||
|
||||
export type {
|
||||
BatchParseResult,
|
||||
FrontmatterResult,
|
||||
NativeBoundaryMapEntry,
|
||||
NativeRoadmap,
|
||||
NativeRoadmapSlice,
|
||||
ParsedGsdFile,
|
||||
SectionResult,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Parse YAML-like frontmatter from markdown content.
|
||||
*
|
||||
* Returns `{ metadata, body }` where `metadata` is a JSON string
|
||||
* of the parsed frontmatter key-value pairs. Parse it with `JSON.parse()`.
|
||||
*/
|
||||
export function parseFrontmatter(content: string): FrontmatterResult {
|
||||
return (native as Record<string, Function>).parseFrontmatter(
|
||||
content,
|
||||
) as FrontmatterResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a section from markdown content by heading name.
|
||||
*
|
||||
* @param content Markdown content to search.
|
||||
* @param heading Heading text to match (without the `#` prefix).
|
||||
* @param level Heading level (default 2 for `##`).
|
||||
*/
|
||||
export function extractSection(
|
||||
content: string,
|
||||
heading: string,
|
||||
level?: number,
|
||||
): SectionResult {
|
||||
return (native as Record<string, Function>).extractSection(
|
||||
content,
|
||||
heading,
|
||||
level,
|
||||
) as SectionResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all sections at a given heading level.
|
||||
*
|
||||
* Returns a JSON string mapping heading names to their content.
|
||||
* Parse with `JSON.parse()`.
|
||||
*/
|
||||
export function extractAllSections(
|
||||
content: string,
|
||||
level?: number,
|
||||
): string {
|
||||
return (native as Record<string, Function>).extractAllSections(
|
||||
content,
|
||||
level,
|
||||
) as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-parse all `.md` files in a `.gsd/` directory tree.
|
||||
*
|
||||
* Reads and parses all markdown files under the given directory.
|
||||
* Each file gets frontmatter parsing and section extraction.
|
||||
*/
|
||||
export function batchParseGsdFiles(
|
||||
directory: string,
|
||||
): BatchParseResult {
|
||||
return (native as Record<string, Function>).batchParseGsdFiles(
|
||||
directory,
|
||||
) as BatchParseResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a roadmap file's content into structured data.
|
||||
*
|
||||
* Extracts title, vision, success criteria, slices (with risk/depends),
|
||||
* and boundary map entries.
|
||||
*/
|
||||
export function parseRoadmapFile(content: string): NativeRoadmap {
|
||||
return (native as Record<string, Function>).parseRoadmapFile(
|
||||
content,
|
||||
) as NativeRoadmap;
|
||||
}
|
||||
62
packages/native/src/gsd-parser/types.ts
Normal file
62
packages/native/src/gsd-parser/types.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* GSD file parser type definitions.
|
||||
*
|
||||
* Types for the native Rust parser that handles `.gsd/` directory files
|
||||
* containing YAML-like frontmatter and markdown sections.
|
||||
*/
|
||||
|
||||
export interface FrontmatterResult {
|
||||
/** Parsed frontmatter as a JSON string of key-value pairs. */
|
||||
metadata: string;
|
||||
/** Body content after the frontmatter block. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface SectionResult {
|
||||
/** The section content, or empty string if not found. */
|
||||
content: string;
|
||||
/** Whether the section was found. */
|
||||
found: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedGsdFile {
|
||||
/** Relative path from the base directory. */
|
||||
path: string;
|
||||
/** Parsed frontmatter as JSON string. */
|
||||
metadata: string;
|
||||
/** Body content after frontmatter. */
|
||||
body: string;
|
||||
/** Map of section heading to content, serialized as JSON. */
|
||||
sections: string;
|
||||
}
|
||||
|
||||
export interface BatchParseResult {
|
||||
/** All parsed files. */
|
||||
files: ParsedGsdFile[];
|
||||
/** Number of files processed. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface NativeRoadmapSlice {
|
||||
id: string;
|
||||
title: string;
|
||||
risk: string;
|
||||
depends: string[];
|
||||
done: boolean;
|
||||
demo: string;
|
||||
}
|
||||
|
||||
export interface NativeBoundaryMapEntry {
|
||||
fromSlice: string;
|
||||
toSlice: string;
|
||||
produces: string;
|
||||
consumes: string;
|
||||
}
|
||||
|
||||
export interface NativeRoadmap {
|
||||
title: string;
|
||||
vision: string;
|
||||
successCriteria: string[];
|
||||
slices: NativeRoadmapSlice[];
|
||||
boundaryMap: NativeBoundaryMapEntry[];
|
||||
}
|
||||
|
|
@ -93,3 +93,19 @@ export type { NativeImageHandle } from "./image/index.js";
|
|||
|
||||
export { ttsrCompileRules, ttsrCheckBuffer, ttsrFreeRules } from "./ttsr/index.js";
|
||||
export type { TtsrHandle, TtsrRuleInput } from "./ttsr/index.js";
|
||||
export {
|
||||
parseFrontmatter,
|
||||
extractSection as nativeExtractSection,
|
||||
extractAllSections,
|
||||
batchParseGsdFiles,
|
||||
parseRoadmapFile,
|
||||
} from "./gsd-parser/index.js";
|
||||
export type {
|
||||
BatchParseResult,
|
||||
FrontmatterResult,
|
||||
NativeBoundaryMapEntry,
|
||||
NativeRoadmap,
|
||||
NativeRoadmapSlice,
|
||||
ParsedGsdFile,
|
||||
SectionResult,
|
||||
} from "./gsd-parser/index.js";
|
||||
|
|
|
|||
|
|
@ -94,4 +94,9 @@ export const native = loadNative() as {
|
|||
ttsrCompileRules: (rules: unknown[]) => number;
|
||||
ttsrCheckBuffer: (handle: number, buffer: string) => string[];
|
||||
ttsrFreeRules: (handle: number) => void;
|
||||
parseFrontmatter: (content: string) => unknown;
|
||||
extractSection: (content: string, heading: string, level?: number) => unknown;
|
||||
extractAllSections: (content: string, level?: number) => string;
|
||||
batchParseGsdFiles: (directory: string) => unknown;
|
||||
parseRoadmapFile: (content: string) => unknown;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type {
|
|||
|
||||
import { checkExistingEnvKeys } from '../get-secrets-from-user.ts';
|
||||
import { parseRoadmapSlices } from './roadmap-slices.ts';
|
||||
import { nativeParseRoadmap, nativeExtractSection, NATIVE_UNAVAILABLE } from './native-parser-bridge.ts';
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -130,6 +131,10 @@ export function parseFrontmatterMap(lines: string[]): Record<string, unknown> {
|
|||
|
||||
/** Extract the text after a heading at a given level, up to the next heading of same or higher level. */
|
||||
export function extractSection(body: string, heading: string, level: number = 2): string | null {
|
||||
// Try native parser first for better performance on large files
|
||||
const nativeResult = nativeExtractSection(body, heading, level);
|
||||
if (nativeResult !== NATIVE_UNAVAILABLE) return nativeResult as string | null;
|
||||
|
||||
const prefix = '#'.repeat(level) + ' ';
|
||||
const regex = new RegExp(`^${prefix}${escapeRegex(heading)}\\s*$`, 'm');
|
||||
const match = regex.exec(body);
|
||||
|
|
@ -182,6 +187,10 @@ export function extractBoldField(text: string, key: string): string | null {
|
|||
// ─── Roadmap Parser ────────────────────────────────────────────────────────
|
||||
|
||||
export function parseRoadmap(content: string): Roadmap {
|
||||
// Try native parser first for better performance
|
||||
const nativeResult = nativeParseRoadmap(content);
|
||||
if (nativeResult) return nativeResult;
|
||||
|
||||
const lines = content.split('\n');
|
||||
|
||||
const h1 = lines.find(l => l.startsWith('# '));
|
||||
|
|
|
|||
135
src/resources/extensions/gsd/native-parser-bridge.ts
Normal file
135
src/resources/extensions/gsd/native-parser-bridge.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// Native GSD Parser Bridge
|
||||
// Provides drop-in replacements for the JS parsing functions in files.ts,
|
||||
// backed by the Rust native parser for better performance on large projects.
|
||||
//
|
||||
// Functions fall back to JS implementations if the native module is unavailable.
|
||||
|
||||
import type { Roadmap, BoundaryMapEntry, RoadmapSliceEntry, RiskLevel } from './types.ts';
|
||||
|
||||
let nativeModule: {
|
||||
parseFrontmatter: (content: string) => { metadata: string; body: string };
|
||||
extractSection: (content: string, heading: string, level?: number) => { content: string; found: boolean };
|
||||
extractAllSections: (content: string, level?: number) => string;
|
||||
batchParseGsdFiles: (directory: string) => { files: Array<{ path: string; metadata: string; body: string; sections: string }>; count: number };
|
||||
parseRoadmapFile: (content: string) => {
|
||||
title: string;
|
||||
vision: string;
|
||||
successCriteria: string[];
|
||||
slices: Array<{ id: string; title: string; risk: string; depends: string[]; done: boolean; demo: string }>;
|
||||
boundaryMap: Array<{ fromSlice: string; toSlice: string; produces: string; consumes: string }>;
|
||||
};
|
||||
} | null = null;
|
||||
|
||||
let loadAttempted = false;
|
||||
|
||||
function loadNative(): typeof nativeModule {
|
||||
if (loadAttempted) return nativeModule;
|
||||
loadAttempted = true;
|
||||
|
||||
try {
|
||||
// Dynamic import to avoid hard dependency - fails gracefully if native module not built
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require('@gsd/native');
|
||||
if (mod.parseFrontmatter && mod.extractSection && mod.batchParseGsdFiles) {
|
||||
nativeModule = mod;
|
||||
}
|
||||
} catch {
|
||||
// Native module not available - all functions fall back to JS
|
||||
}
|
||||
|
||||
return nativeModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native-backed frontmatter splitting.
|
||||
* Returns [parsedMetadata, body] where parsedMetadata is the parsed key-value map.
|
||||
*/
|
||||
export function nativeSplitFrontmatter(content: string): { metadata: Record<string, unknown>; body: string } | null {
|
||||
const native = loadNative();
|
||||
if (!native) return null;
|
||||
|
||||
const result = native.parseFrontmatter(content);
|
||||
return {
|
||||
metadata: JSON.parse(result.metadata) as Record<string, unknown>,
|
||||
body: result.body,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sentinel value indicating the native module is not available. */
|
||||
const NATIVE_UNAVAILABLE = Symbol('native-unavailable');
|
||||
|
||||
/**
|
||||
* Native-backed section extraction.
|
||||
* Returns section content, null if not found, or NATIVE_UNAVAILABLE symbol
|
||||
* if the native module isn't loaded.
|
||||
*/
|
||||
export function nativeExtractSection(content: string, heading: string, level: number = 2): string | null | typeof NATIVE_UNAVAILABLE {
|
||||
const native = loadNative();
|
||||
if (!native) return NATIVE_UNAVAILABLE;
|
||||
|
||||
const result = native.extractSection(content, heading, level);
|
||||
return result.found ? result.content : null;
|
||||
}
|
||||
|
||||
export { NATIVE_UNAVAILABLE };
|
||||
|
||||
/**
|
||||
* Native-backed roadmap parsing.
|
||||
* Returns a Roadmap object or null if native module unavailable.
|
||||
*/
|
||||
export function nativeParseRoadmap(content: string): Roadmap | null {
|
||||
const native = loadNative();
|
||||
if (!native) return null;
|
||||
|
||||
const result = native.parseRoadmapFile(content);
|
||||
return {
|
||||
title: result.title,
|
||||
vision: result.vision,
|
||||
successCriteria: result.successCriteria,
|
||||
slices: result.slices.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
risk: s.risk as RiskLevel,
|
||||
depends: s.depends,
|
||||
done: s.done,
|
||||
demo: s.demo,
|
||||
})),
|
||||
boundaryMap: result.boundaryMap.map(b => ({
|
||||
fromSlice: b.fromSlice,
|
||||
toSlice: b.toSlice,
|
||||
produces: b.produces,
|
||||
consumes: b.consumes,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export interface BatchParsedFile {
|
||||
path: string;
|
||||
metadata: Record<string, unknown>;
|
||||
body: string;
|
||||
sections: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-parse all .md files in a .gsd/ directory tree using the native parser.
|
||||
* Returns null if native module unavailable.
|
||||
*/
|
||||
export function nativeBatchParseGsdFiles(directory: string): BatchParsedFile[] | null {
|
||||
const native = loadNative();
|
||||
if (!native) return null;
|
||||
|
||||
const result = native.batchParseGsdFiles(directory);
|
||||
return result.files.map(f => ({
|
||||
path: f.path,
|
||||
metadata: JSON.parse(f.metadata) as Record<string, unknown>,
|
||||
body: f.body,
|
||||
sections: JSON.parse(f.sections) as Record<string, string>,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the native parser is available.
|
||||
*/
|
||||
export function isNativeParserAvailable(): boolean {
|
||||
return loadNative() !== null;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue