Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rust-language-extractor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@razakadam74/gitmsg': minor
---

Add Rust language extractor for symbol-aware subject lines on `.rs` files. Recognises top-level `fn`, `struct`, `enum`, `trait`, `type` aliases, and `const` declarations — including generics, `async`/`unsafe`/`const`/`extern "C"` function qualifiers, and `unsafe` auto traits. Visibility comes from the `pub` line prefix, so `pub(crate)`/`pub(super)` read as non-exported. Methods (indented inside `impl`/`trait` blocks), inline-module items, `static`, `union`, and `macro_rules!` are out of scope; see [`docs/languages/rust.md`](https://github.com/razakadam74/gitmsg/blob/main/docs/languages/rust.md) for the full pattern ladder and blind spots.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Symbol-aware subject lines (e.g. `feat(auth): add rotateRefreshToken`) are suppo
| C# | `.cs`, `.csx` |
| Go | `.go` |
| Java | `.java` |
| Rust | `.rs` |

Files in other languages still get a sensible commit message — the subject just falls back to a generic verb (e.g. `docs: update README`, `feat(parser): refactor module`) instead of naming specific symbols. Adding a language is a small, well-scoped contribution; see the recipe in [`docs/heuristics.md`](docs/heuristics.md#adding-a-new-language).

Expand Down Expand Up @@ -98,7 +99,7 @@ Edit the message — `gitmsg --edit` opens your editor pre-filled. If a particul

## Status

🚧 Early development. Published as [`@razakadam74/gitmsg`](https://www.npmjs.com/package/@razakadam74/gitmsg) with TypeScript/JavaScript, Python, C#, Go, and Java extractors. APIs and behaviour may still change before v1.0.
🚧 Early development. Published as [`@razakadam74/gitmsg`](https://www.npmjs.com/package/@razakadam74/gitmsg) with TypeScript/JavaScript, Python, C#, Go, Java, and Rust extractors. APIs and behaviour may still change before v1.0.

## Contributing

Expand Down
120 changes: 120 additions & 0 deletions docs/languages/rust.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Rust

Symbol extraction for `.rs` files.

Source: [`src/languages/rust.ts`](../../src/languages/rust.ts).

## Pattern ladder

Six regex patterns. First match per line wins. All patterns are anchored at `^`
(column 0, no leading whitespace) — see the anchor decision below.

| # | Pattern shape | Kind |
| --- | ----------------- | ----------- |
| 1 | `… fn NAME(…)` | `function` |
| 2 | `… struct NAME` | `class` |
| 3 | `… enum NAME` | `type` |
| 4 | `… trait NAME` | `interface` |
| 5 | `… type NAME …` | `type` |
| 6 | `… const NAME: …` | `const` |

Every row starts with the same optional visibility prefix
`(?:pub(?:\([^)]*\))?\s+)?` — it eats `pub` or a restricted form like
`pub(crate)` / `pub(super)` / `pub(in path)` before the keyword. Visibility
itself is computed separately (see below); the prefix only exists so the keyword
and name still line up when a `pub` is present.

The `fn` row carries two extra eaters: `(?:(?:async|unsafe|const)\s+)*` for
function qualifiers and `(?:extern\s+"[^"]*"\s+)?` for `extern "C"`-style ABI
markers. The `trait` row eats an optional leading `unsafe` (auto traits like
`unsafe trait Send`).

Row 1 (`fn`) **must** precede row 6 (`const`): a `const fn` is a function, not a
const. Two things pin this — `fn` is listed first, and the `const` row requires a
trailing `:` (`const NAME:`), which a `const fn foo()` declaration never has. So
even if the order were swapped, `const fn` would still fall through to the `fn`
row.

Generic type parameters (`<T>`, `<T: Trait>`, `<const N: usize>`) are not
captured — the name capture stops at the bare identifier, and the angle-bracket
list is part of the declaration, not the name.

### The anchor (the first decision)

> 💡 **Decision:** All rows anchor at `^` (column 0), like Go — **not** `^\s*`
> like C#/Java. Rust methods live inside indented `impl` and `trait` blocks and
> are syntactically identical to free functions (`fn name(...)`). A column-0
> anchor is the only single-line way to tell a free function from a method: free
> items sit at column 0, members are indented. The cost is that items declared
> inside an inline `mod foo { … }` block (also indented) are missed — accepted,
> because file-based modules (`mod foo;` + `foo.rs`) are the norm.

### Visibility (the headline decision)

> 💡 **Decision:** Visibility comes from the **line prefix**:
> `exported = /^pub\s/.test(line)`. Only a bare `pub` followed by whitespace
> exports. `pub(crate)`, `pub(super)`, and `pub(in path)` all read as
> `exported: false` — after `pub` comes a `(`, not whitespace, so the test
> fails. Items with no `pub` are private and also read as `exported: false`.

This is the sixth visibility model in the codebase:

| Extractor | Source | Computation |
| --------- | --------------- | ------------------------------- |
| TS | regex row | `export` literal in the pattern |
| Python | name | `!name.startsWith('_')` |
| C# | line prefix | `/^\s*public\s/.test(line)` |
| Go | name | `/^[A-Z]/.test(name)` |
| Java | line prefix | `/^\s*public\s+/.test(line)` |
| **Rust** | **line prefix** | **`/^pub\s/.test(line)`** |

Rust is a hybrid: it shares the **line-prefix** visibility model with C#/Java,
but the **column-0 anchor** with Go. The line prefix carries no `\s*` because the
column-0 anchor already forbids leading whitespace — restricted visibility
(`pub(crate)`) is excluded by the absence of a space after `pub`, not by the
anchor.

## Dedup key

Per-file extraction uses `${kind}:${name}:${exported}`. Same shape as every other
extractor — see the [heuristics overview](../heuristics.md#the-dedup-key-distinction)
for the cross-file vs per-file rationale.

## v1 blind spots

Documented limitations, accepted as tradeoffs against the "single-line regex, no
parser" design:

- **Methods.** `fn` declarations inside `impl`/`trait` blocks are indented, so the
column-0 anchor skips them. A struct's inherent methods (`impl Foo { pub fn new() }`)
are not extracted; only the `struct Foo` line is. Same philosophy as Java —
naming the type is honest; over-claiming every method is not.
- **Inline-module items.** Items inside `mod foo { … }` are indented and therefore
missed. File-based modules are unaffected (their items are at column 0).
- **`static` items.** `static MAX: u32 = …` is not extracted (no row). Globals are
rarer than `const` in public APIs; add a row if a real codebase needs it.
- **`union` and `macro_rules!`.** Unions (FFI-only, rare) and declarative macros
have no row.
- **`pub(crate) fn` parameter capture.** The shared callable helper scans the line
for the first `(…)`. For `pub(crate) fn foo(a: i32)` that first paren is the
visibility `(crate)`, so `params` becomes `"crate"` instead of `"a: i32"`. The
symbol is non-exported anyway, so this only affects signature-change detection of
crate-private functions — accepted. Plain `pub fn` and private `fn` capture params
correctly.
- **Block comments masking declarations.** `/* pub fn foo() {` still matches the
`fn` row; the regex doesn't track multi-line comment state.

These are the right places for a future tree-sitter upgrade to land. Until then,
the fix for "this exact codebase trips a blind spot" is a fixture.

## File extensions

Match regex: `/\.rs$/`. Covers:

- `.rs` — Rust source files

Deliberately **not** matched:

- `.rss` — RSS feeds (not Rust)
- `.rlib` — compiled Rust library (binary, not source)
- `Cargo.toml` / `Cargo.lock` — manifests, not source
2 changes: 2 additions & 0 deletions src/languages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { csExtractor } from './cs.js';
import { goExtractor } from './go.js';
import { javaExtractor } from './java.js';
import { pyExtractor } from './py.js';
import { rustExtractor } from './rust.js';
import { tsExtractor } from './ts.js';

export const extractors: LanguageExtractor[] = [
Expand All @@ -17,6 +18,7 @@ export const extractors: LanguageExtractor[] = [
csExtractor,
goExtractor,
javaExtractor,
rustExtractor,
];

export function extractorFor(path: string): LanguageExtractor | undefined {
Expand Down
26 changes: 26 additions & 0 deletions src/languages/rust.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { CodeSymbol, LanguageExtractor } from '../types.js';
import { runPatterns } from './runner.js';

const PATTERNS: Array<{ re: RegExp; kind: CodeSymbol['kind']; callable?: boolean }> = [
{
re: /^(?:pub(?:\([^)]*\))?\s+)?(?:(?:async|unsafe|const)\s+)*(?:extern\s+"[^"]*"\s+)?fn\s+(\w+)/,
kind: 'function',
callable: true,
},
{ re: /^(?:pub(?:\([^)]*\))?\s+)?struct\s+(\w+)/, kind: 'class' },
{ re: /^(?:pub(?:\([^)]*\))?\s+)?enum\s+(\w+)/, kind: 'type' },
{ re: /^(?:pub(?:\([^)]*\))?\s+)?(?:unsafe\s+)?trait\s+(\w+)/, kind: 'interface' },
{ re: /^(?:pub(?:\([^)]*\))?\s+)?type\s+(\w+)/, kind: 'type' },
{ re: /^(?:pub(?:\([^)]*\))?\s+)?const\s+(\w+)\s*:/, kind: 'const' },
];

const EXPORT_RE = /^pub\s/;

export const rustExtractor: LanguageExtractor = {
matches(path: string): boolean {
return /\.rs$/.test(path);
},
extract(lines: string[]) {
return runPatterns(lines, PATTERNS, (line) => EXPORT_RE.test(line));
},
};
22 changes: 22 additions & 0 deletions tests/fixtures/feat-rust-lexer.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
diff --git a/src/parser/lexer.rs b/src/parser/lexer.rs
new file mode 100644
index 0000000..3333333
--- /dev/null
+++ b/src/parser/lexer.rs
@@ -0,0 +1,16 @@
+pub struct Lexer {
+ input: String,
+}
+
+pub fn tokenize(src: &str) -> Vec<Token> {
+ Vec::new()
+}
+
+pub enum Token {
+ Ident,
+ Number,
+}
+
+pub const MAX_TOKENS: usize = 1024;
+
+fn helper() {}
1 change: 1 addition & 0 deletions tests/fixtures/feat-rust-lexer.expected.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
feat(parser): add Lexer and others
8 changes: 8 additions & 0 deletions tests/languages-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { csExtractor } from '../src/languages/cs.js';
import { goExtractor } from '../src/languages/go.js';
import { javaExtractor } from '../src/languages/java.js';
import { pyExtractor } from '../src/languages/py.js';
import { rustExtractor } from '../src/languages/rust.js';
import { tsExtractor } from '../src/languages/ts.js';

const allCases: Array<{ name: string; ex: LanguageExtractor; classLine: string }> = [
Expand All @@ -12,6 +13,7 @@ const allCases: Array<{ name: string; ex: LanguageExtractor; classLine: string }
{ name: 'cs', ex: csExtractor, classLine: 'public class Foo {}' },
{ name: 'go', ex: goExtractor, classLine: 'type Foo struct {}' },
{ name: 'java', ex: javaExtractor, classLine: 'public class Foo {}' },
{ name: 'rust', ex: rustExtractor, classLine: 'pub struct Foo {}' },
];

const callableCases: Array<{
Expand All @@ -34,6 +36,12 @@ const callableCases: Array<{
callLine: 'public delegate int Bar();',
},
{ name: 'go', ex: goExtractor, classLine: 'type Foo struct {}', callLine: 'func Bar() {}' },
{
name: 'rust',
ex: rustExtractor,
classLine: 'pub struct Foo {}',
callLine: 'pub fn bar() {}',
},
];

describe('Language contract — all extractors', () => {
Expand Down
13 changes: 12 additions & 1 deletion tests/languages-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { csExtractor } from '../src/languages/cs.js';
import { goExtractor } from '../src/languages/go.js';
import { javaExtractor } from '../src/languages/java.js';
import { pyExtractor } from '../src/languages/py.js';
import { rustExtractor } from '../src/languages/rust.js';
import { tsExtractor } from '../src/languages/ts.js';

const file = (overrides: Partial<FileChange> = {}): FileChange => ({
Expand Down Expand Up @@ -35,6 +36,10 @@ describe('extractors registry', () => {
it('includes javaExtractor', () => {
expect(extractors).toContain(javaExtractor);
});

it('includes rustExtractor', () => {
expect(extractors).toContain(rustExtractor);
});
});

describe('extractorFor', () => {
Expand All @@ -58,8 +63,14 @@ describe('extractorFor', () => {
expect(extractorFor(p)).toBe(javaExtractor),
);

it.each(['a.rs', 'src/main.rs', 'crates/core/src/lib.rs'])('returns rustExtractor for %s', (p) =>
expect(extractorFor(p)).toBe(rustExtractor),
);

it.each([
'a.rs',
'a.rss',
'Cargo.toml',
'a.rlib',
'a.gohtml',
'a.tmpl',
'go.mod',
Expand Down
Loading
Loading