Skip to content

Commit ffcbeae

Browse files
committed
feat: implement Rust support
1 parent af6c73f commit ffcbeae

File tree

20 files changed

+765
-45
lines changed

20 files changed

+765
-45
lines changed

.gitignore

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,15 @@ Thumbs.db
5454
.vscode/
5555

5656
# CodeI18n specific
57-
.codei18n/
57+
.codei18n/
58+
59+
# Rust artifacts (for test data or sub-projects)
60+
target/
61+
debug/
62+
release/
63+
*.rs.bk
64+
*.rlib
65+
66+
# C/C++ artifacts (for CGO)
67+
*.o
68+
*.a

adapters/registry.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package adapters
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"strings"
7+
8+
"github.com/studyzy/codei18n/adapters/golang"
9+
"github.com/studyzy/codei18n/adapters/rust"
10+
"github.com/studyzy/codei18n/core"
11+
)
12+
13+
// GetAdapter returns the appropriate LanguageAdapter for the given file
14+
func GetAdapter(filename string) (core.LanguageAdapter, error) {
15+
ext := strings.ToLower(filepath.Ext(filename))
16+
switch ext {
17+
case ".go":
18+
return golang.NewAdapter(), nil
19+
case ".rs":
20+
return rust.NewRustAdapter(), nil
21+
default:
22+
return nil, fmt.Errorf("unsupported file extension: %s", ext)
23+
}
24+
}

adapters/rust/acceptance_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package rust
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestRustAcceptance(t *testing.T) {
11+
src := []byte(`
12+
// File header
13+
14+
/// Function Doc
15+
fn main() {
16+
// Inner logic
17+
println!("Hello");
18+
}
19+
20+
pub mod my_mod {
21+
//! Module Doc
22+
23+
/// Struct Doc
24+
#[derive(Debug)]
25+
pub struct MyStruct;
26+
}
27+
`)
28+
adapter := NewRustAdapter()
29+
comments, err := adapter.Parse("test.rs", src)
30+
assert.NoError(t, err)
31+
assert.Len(t, comments, 5, "Should extract 5 comments")
32+
33+
// Helper map to find comments by content
34+
commentMap := make(map[string]string)
35+
for _, c := range comments {
36+
key := strings.TrimSpace(c.SourceText)
37+
commentMap[key] = c.Symbol
38+
}
39+
40+
// 1. File header
41+
assert.Equal(t, "", commentMap["// File header"])
42+
43+
// 2. /// Function Doc -> Owner is fn main -> Path "main"
44+
assert.Equal(t, "main", commentMap["/// Function Doc"])
45+
46+
// 3. // Inner logic -> Owner is block -> Parent is fn main -> Path "main"
47+
assert.Equal(t, "main", commentMap["// Inner logic"])
48+
49+
// 4. //! Module Doc -> Owner is mod my_mod -> Path "my_mod"
50+
assert.Equal(t, "my_mod", commentMap["//! Module Doc"])
51+
52+
// 5. /// Struct Doc -> Owner is struct MyStruct (skip attr) -> Path "my_mod::MyStruct"
53+
assert.Equal(t, "my_mod::MyStruct", commentMap["/// Struct Doc"])
54+
}

adapters/rust/adapter.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package rust
2+
3+
import (
4+
"context"
5+
6+
sitter "github.com/smacker/go-tree-sitter"
7+
"github.com/smacker/go-tree-sitter/rust"
8+
"github.com/studyzy/codei18n/core/domain"
9+
)
10+
11+
// RustAdapter implements the LanguageAdapter interface for Rust language.
12+
// It uses Tree-sitter to parse Rust source code and extract comments with their context.
13+
type RustAdapter struct {
14+
parser *sitter.Parser
15+
}
16+
17+
// NewRustAdapter creates a new instance of RustAdapter.
18+
// It initializes the Tree-sitter parser with the Rust grammar.
19+
func NewRustAdapter() *RustAdapter {
20+
p := sitter.NewParser()
21+
p.SetLanguage(rust.GetLanguage())
22+
return &RustAdapter{parser: p}
23+
}
24+
25+
// Language returns the language identifier ("rust").
26+
func (a *RustAdapter) Language() string {
27+
return "rust"
28+
}
29+
30+
// Parse parses the provided Rust source code and extracts comments.
31+
// It returns a list of domain.Comment structs containing comment text, position, and context symbol.
32+
// If src is nil, it currently expects the caller to handle file reading (TODO: implement file reading).
33+
func (a *RustAdapter) Parse(file string, src []byte) ([]*domain.Comment, error) {
34+
// Parse the source code using Tree-sitter
35+
tree, err := a.parser.ParseCtx(context.Background(), nil, src)
36+
if err != nil {
37+
return nil, err
38+
}
39+
defer tree.Close()
40+
41+
return a.extractComments(tree.RootNode(), src, file)
42+
}

adapters/rust/adapter_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package rust
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestRustParser(t *testing.T) {
10+
adapter := NewRustAdapter()
11+
assert.Equal(t, "rust", adapter.Language())
12+
13+
src := []byte("fn main() { println!(\"Hello\"); }")
14+
comments, err := adapter.Parse("main.rs", src)
15+
assert.NoError(t, err)
16+
assert.Empty(t, comments) // No comments in this source
17+
}

adapters/rust/context.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package rust
2+
3+
import (
4+
"strings"
5+
6+
sitter "github.com/smacker/go-tree-sitter"
7+
)
8+
9+
// CommentType represents the type of Rust comment.
10+
type CommentType int
11+
12+
const (
13+
// NormalComment represents standard comments (// or /* */).
14+
NormalComment CommentType = iota
15+
// DocComment represents outer documentation comments (/// or /** */).
16+
DocComment
17+
// ModuleDocComment represents inner/module documentation comments (//! or /*! */).
18+
ModuleDocComment
19+
)
20+
21+
// IdentifyCommentType determines the type of comment based on its content prefix.
22+
func IdentifyCommentType(content string) CommentType {
23+
if strings.HasPrefix(content, "///") || strings.HasPrefix(content, "/**") {
24+
return DocComment
25+
}
26+
if strings.HasPrefix(content, "//!") || strings.HasPrefix(content, "/*!") {
27+
return ModuleDocComment
28+
}
29+
return NormalComment
30+
}
31+
32+
// FindOwnerNode finds the semantic owner node of the comment.
33+
// For DocComments, it looks for the next semantic sibling (skipping attributes).
34+
// For ModuleDocComments and NormalComments, it returns the parent node (representing the scope).
35+
func FindOwnerNode(commentNode *sitter.Node, src []byte) *sitter.Node {
36+
content := commentNode.Content(src)
37+
cType := IdentifyCommentType(content)
38+
39+
if cType == DocComment {
40+
curr := commentNode.NextNamedSibling()
41+
for curr != nil {
42+
// Skip attributes (#[...]) to find the actual item
43+
if curr.Type() == "attribute_item" {
44+
curr = curr.NextNamedSibling()
45+
continue
46+
}
47+
return curr
48+
}
49+
// Fallback to parent if no sibling found (orphaned doc comment)
50+
return commentNode.Parent()
51+
}
52+
53+
// ModuleDoc and NormalDoc belong to Parent scope
54+
return commentNode.Parent()
55+
}

adapters/rust/edge_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package rust
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestEdgeCases(t *testing.T) {
10+
// Case 1: Comments inside string literals should NOT be extracted
11+
src := []byte(`
12+
fn main() {
13+
let s = "// This is not a comment";
14+
let s2 = "/* Neither is this */";
15+
// This is a comment
16+
}
17+
`)
18+
adapter := NewRustAdapter()
19+
comments, err := adapter.Parse("edge.rs", src)
20+
assert.NoError(t, err)
21+
assert.Len(t, comments, 1)
22+
assert.Equal(t, "// This is a comment", comments[0].SourceText)
23+
}
24+
25+
func TestMacros(t *testing.T) {
26+
// Case 2: Comments inside macro invocations
27+
src := []byte(`
28+
macro_rules! say_hello {
29+
() => {
30+
// Comment inside macro
31+
println!("Hello");
32+
};
33+
}
34+
35+
fn main() {
36+
say_hello!();
37+
}
38+
`)
39+
adapter := NewRustAdapter()
40+
comments, err := adapter.Parse("macro.rs", src)
41+
assert.NoError(t, err)
42+
43+
// Tree-sitter usually parses macro_rules! body as token tree or specific structure
44+
// Let's see if it catches the comment.
45+
// Update: It depends on how 'macro_rules' is defined in grammar.
46+
// If it extracts it, great. If not, it's acceptable for now (as per Spec).
47+
48+
if len(comments) > 0 {
49+
assert.Contains(t, comments[0].SourceText, "Comment inside macro")
50+
}
51+
}

adapters/rust/extractor.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package rust
2+
3+
import (
4+
"strings"
5+
6+
sitter "github.com/smacker/go-tree-sitter"
7+
"github.com/smacker/go-tree-sitter/rust"
8+
"github.com/studyzy/codei18n/core/domain"
9+
"github.com/studyzy/codei18n/core/utils"
10+
)
11+
12+
// extractComments processes the AST and extracts all comments matching the query.
13+
func (a *RustAdapter) extractComments(root *sitter.Node, src []byte, file string) ([]*domain.Comment, error) {
14+
q, err := sitter.NewQuery([]byte(rustCommentQuery), rust.GetLanguage())
15+
if err != nil {
16+
return nil, err
17+
}
18+
19+
qc := sitter.NewQueryCursor()
20+
qc.Exec(q, root)
21+
defer qc.Close() // Ensure cursor is closed
22+
23+
var comments []*domain.Comment
24+
25+
for {
26+
m, ok := qc.NextMatch()
27+
if !ok {
28+
break
29+
}
30+
31+
for _, c := range m.Captures {
32+
node := c.Node
33+
content := node.Content(src)
34+
35+
// Find Owner and Symbol Path
36+
owner := FindOwnerNode(node, src)
37+
symbolPath := ResolveSymbolPath(owner, src)
38+
39+
comment := &domain.Comment{
40+
SourceText: content,
41+
Symbol: symbolPath,
42+
File: file,
43+
Language: "rust",
44+
Range: domain.TextRange{
45+
StartLine: int(node.StartPoint().Row) + 1,
46+
StartCol: int(node.StartPoint().Column) + 1,
47+
EndLine: int(node.EndPoint().Row) + 1,
48+
EndCol: int(node.EndPoint().Column) + 1,
49+
},
50+
Type: getDomainCommentType(content),
51+
}
52+
comment.ID = utils.GenerateCommentID(comment)
53+
comments = append(comments, comment)
54+
}
55+
}
56+
57+
return comments, nil
58+
}
59+
60+
// getDomainCommentType maps raw comment content to domain.CommentType
61+
func getDomainCommentType(content string) domain.CommentType {
62+
if strings.HasPrefix(content, "/*") {
63+
return domain.CommentTypeBlock
64+
}
65+
if strings.HasPrefix(content, "///") || strings.HasPrefix(content, "//!") {
66+
return domain.CommentTypeDoc
67+
}
68+
return domain.CommentTypeLine
69+
}

adapters/rust/queries.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package rust
2+
3+
// rustCommentQuery is the Tree-sitter query to extract comments.
4+
// It matches both line comments (//) and block comments (/* */).
5+
const rustCommentQuery = `
6+
(line_comment) @comment
7+
(block_comment) @comment
8+
`

0 commit comments

Comments
 (0)