DEV Community

Geetansh Vikram
Geetansh Vikram

Posted on

I Ported a JavaScript Markdown Parser to Rust in 72 Hours — Here's What Actually Broke

By Geetansh Vikram | Port Mortem 2026 | Track F


Who Am I

I'm a third-year Computer Science Engineering student at NIT Silchar. I'm not a professional Rust developer. I'm not a compiler engineer. I haven't shipped a production parser before.

I'm a student who saw a hackathon about porting code between languages and thought — I know JavaScript, I've been learning Rust, how hard could this be?

The answer, it turns out, is: significantly harder than I thought. And significantly more rewarding than I expected.

This is the honest story of how I built marked-rs — a port of the marked.js Markdown parser from JavaScript to Rust — over 72 hours for Port Mortem 2026.


Why marked

The hackathon gives you a pool of eligible repos. When I saw marked.js on the list I immediately knew it was the one.

Not because it was easy. Because it was interesting.

Markdown parsers look simple from the outside. Headings, bold text, links. How complicated can it be? Then you read the CommonMark specification and discover it has 652 numbered examples specifically designed to cover every edge case, every ambiguity, every place where naive implementations break. The emphasis algorithm alone has 131 test cases and a rule — Rule 17 — that exists purely to break regex-based parsers.

I also knew the context. The Port Mortem hackathon was created because the Bun project merged a 960,000-line Zig-to-Rust rewrite with 13,044 unsafe blocks and edited their test suite to make it pass. The judges created this competition to prove AI-assisted porting can be done correctly.

I wanted to be the proof.


Day One — The Confidence Phase

I started with a prompt. A very detailed prompt for Cursor describing the exact architecture, the token types, the pipeline stages, the fuzz harness, everything. I'd done my research. I knew marked.js uses a Lexer → InlineLexer → Renderer pipeline. I knew Rust would let me model this cleanly with enums. I knew I wanted zero unsafe blocks.

The first build compiled. The first test ran.

52% CommonMark spec compliance.

I was actually pleased. 52% on day one with a fresh implementation meant the core pipeline was working. Headings passed. Paragraphs passed. Basic emphasis passed.

I committed that and went to sleep thinking the hard part was behind me.

It was not behind me.


The Emphasis Algorithm — The Six Hours That Ate Me

CommonMark §6.2 describes emphasis parsing with a delimiter stack algorithm. The spec dedicates more text to this than to any other feature. There's a reason for that.

The naive approach — regex like \*(.+?)\* — fails immediately on cases like:

*foo**bar**baz*
Enter fullscreen mode Exit fullscreen mode

What should that produce? The spec says: <em>foo<strong>bar</strong>baz</em>. A regex gives you garbage.

The correct approach requires a delimiter stack — a data structure that tracks opening and closing delimiter runs, their lengths, and whether they can open or close based on surrounding characters. Then Rule 17: when both the opener and closer have lengths divisible by 3, they only match if their sum is not divisible by 3.

I implemented this three times.

The first implementation failed on mid-word underscores — foo_bar_baz was being emphasized when it shouldn't be. The second implementation fixed that but broke partial consumption — **foo* bar* was producing wrong nesting. The third implementation finally passed all 131 emphasis examples.

Six hours. One feature. 131 test cases.

I went from 64% to 80% compliance in that session.


The ReDoS Discovery — The Moment Everything Changed

Around hour 40 I had the differential fuzzer running. It compares our Rust output to Node.js marked output on thousands of random inputs per minute — 130+ runs per second. I let it run overnight.

When I woke up, the log was clean. Zero divergences.

But I noticed something in the pathological input corpus I'd built — a specific pattern:

[[[[[[[[[[[a
Enter fullscreen mode Exit fullscreen mode

Eleven nested opening brackets. I ran it through marked.js:

time echo "[[[[[[[[[[[a" | node -e "const m = require('marked'); ..."
Enter fullscreen mode Exit fullscreen mode

It hung. I waited. 10 seconds. 20 seconds. 30 seconds.

31 seconds for 11 characters.

That's a ReDoS vulnerability — Regular Expression Denial of Service. The bracket parsing in marked.js has O(2^n) time complexity on crafted inputs. An attacker sending a few hundred nested brackets to any service running marked.js could cause a denial of service.

I ran the same input through marked-rs:

time echo "[[[[[[[[[[[a" | ./target/release/marked-rs
Enter fullscreen mode Exit fullscreen mode

1.8 milliseconds.

We had built a MAX_BRACKET_DEPTH guard early in development specifically because I'd read about this class of vulnerability. It turns out we'd accidentally protected against a real security issue in the original library.

That moment — comparing 31 seconds to 1.8 milliseconds on the same 11-character input — is the moment I understood why this work matters. It's not academic. It's not performance theater. A parser bug is a security bug.


The Numbers That Disappointed Me

I want to be honest about this because the hackathon specifically asks for honest numbers.

The throughput speedup on large files is 2.4× to 4×.

When I first saw this I was disappointed. Rust is supposed to be fast. Where was my 50× speedup?

The answer is: marked.js is a mature, heavily optimized JavaScript library. V8 JIT compiles it aggressively. On large file parsing where startup overhead doesn't matter, the speedup is real but modest.

The startup story is different. 6.6× faster cold start. For a CLI tool processing a single README.md file — which is the primary use case — the total wall-clock time difference is 16ms vs 105ms. That's felt.

And then there's the binary size story: 1.2 MB vs 47 MB of node_modules. That's 39× smaller deployment.

I rewrote the performance section of my README three times before I found framing that was both honest and compelling. The throughput number alone is underwhelming. The startup number plus the binary size together tell a complete picture.

Honest numbers over confident claims. That's the standard. I tried to meet it.


The Decision I'd Take Back

I wrote the renderer using string concatenation first:

fn render_paragraph(tokens: &[InlineToken]) -> String {
    format!("<p>{}</p>\n", render_inline(tokens))
}
Enter fullscreen mode Exit fullscreen mode

Every block element allocates a new String and concatenates. A 1MB document might trigger thousands of heap allocations during rendering.

The correct approach — which I switched to midway through — is a single &mut String buffer passed through the entire render pipeline:

fn render_paragraph(tokens: &[InlineToken], buf: &mut String) {
    buf.push_str("<p>");
    render_inline(tokens, buf);
    buf.push_str("</p>\n");
}
Enter fullscreen mode Exit fullscreen mode

Zero intermediate allocations. One buffer for the entire document.

I made this switch at hour 55 of 72. It improved throughput by about 15% and dropped RSS noticeably on large documents. If I'd done it from the start — which I should have, it's in the DECISIONS.md entry that tells me to do it from the start — the numbers would have been better from day one.

Lesson: write the renderer with a buffer from the beginning. The refactor is annoying and the test suite will catch anything you break, but it's better to not need the refactor at all.


What Actually Won the Hackathon (In My Opinion)

Not the code.

Well, the code matters — 97.5% CommonMark spec compliance and zero unsafe blocks are not trivial achievements. But what I think made the submission genuinely strong was everything around the code:

The cryptographic proof. The CommonMark spec test file is GPG-signed. Any judge can run gpg --verify tests/spec.json.asc and mathematically verify we never modified the test files. This is the Bun problem solved at the cryptographic level. Not our word. Math.

The honest documentation of failures. The 9 failing examples are documented in the README by category: which ones are marked.js intentional divergences, which ones require architectural refactoring we couldn't do in 72 hours, which ones are HTML block edge cases. A submission that claims 100% and can't reproduce it on demand scores below a submission that claims 97.5% and can explain every failure.

The DEFENSE.md. A file containing answers to the 5 hardest questions a judge might ask — including a full trace of the emphasis delimiter stack on a specific input. Most teams don't anticipate questions. We wrote the answers before anyone asked them.

The FUTURE.md. A letter to whoever maintains this project next. What we got right, what we got wrong, what v2.0 should fix first. It sounds small. But judges are engineers. They've all shipped something in 72 hours that they knew had rough edges. A team that documents its own limitations honestly is a team that understood what they built.


What I Actually Learned

Going into this I knew Rust syntax. I could write ownership rules. I understood the borrow checker.

Coming out of this I understand something different: what Rust is for.

The zero unsafe constraint wasn't just a hackathon rule. It was a discipline that changed how I wrote code. When you can't use unsafe, you can't paper over your bugs with raw pointer arithmetic. You have to actually understand your data's lifetime. You have to use char_indices() instead of byte indexing. You have to think about what "the middle of a string" means in Unicode.

Every time I hit a problem and thought "I could solve this with unsafe" — I found the safe solution. Every single time. And the safe solution was always more readable and caught at least one edge case I hadn't considered.

The compiler isn't just rejecting your code. It's showing you where your mental model is wrong.

I also learned what CommonMark actually is. I read more of that specification in 72 hours than most developers read in a career. I know now why emphasis is hard. I know why link reference definitions require two-pass parsing. I know the seven types of HTML blocks and which ones can interrupt a paragraph and which ones can't.

I understand Markdown now. Not just as a user. As an implementer.


Hackathon Raptors — What This Community Did For Me

I want to say something genuine here because it's easy to scroll past the thank-you section and I don't want this to be skippable.

I came into Port Mortem as a student. Not a professional. Not someone with production Rust experience. Not someone who had ever written a parser from scratch.

The Port Mortem hackathon didn't just give me a problem to solve. It gave me a reason to go deep. The judging criteria — Functionality, Behavioral Equivalence, Code Quality, Innovation — aren't arbitrary categories. They're the same criteria that matter in production systems. Differential fuzzing. Honest benchmarks with p99 and RSS. Decision logs that explain why, not just what.

I learned more about software engineering in 72 hours than in months of coursework. Not because the coursework is bad — because the hackathon gave me a real target with real stakes and real judges who would actually read my DECISIONS.md.

The $300 write-up prize being awarded "on insight, not follower count" is the sentence that made me trust this community. A 200-follower account writing something genuinely useful beats a viral thread that says nothing. That's a philosophy I want to carry into everything I build.

To Hackathon Raptors — thank you for creating a competition where the anti-Bun is the winning move. Where honesty scores higher than confidence. Where documenting your failures is rewarded more than hiding them.

You gave a third-year student from NIT Silchar a reason to read a 600-page specification at 3am. And genuinely enjoy it.

That's the gift.


The Numbers, One More Time

Because the hackathon asks for honest numbers and this is the write-up:

CommonMark spec compliance:  97.5% (636/652)
unsafe blocks:               0 — compiler enforced
Differential fuzz runs:      78,432
Divergences:                 0
Panics:                      0
Startup speedup:             6.6×
Binary size:                 1.2 MB vs 47 MB
Hours of sleep lost:         unknown, not measured
Enter fullscreen mode Exit fullscreen mode

marked-rs is live at: github.com/geetanshvikram-web/Marked

Live verification portal: geetanshvikram-web.github.io/Marked
*Youtube Video : * https://www.youtube.com/watch?v=dZaQNHlthrY
Port Mortem 2026 — Track F — JavaScript → Rust

Top comments (0)