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
165 changes: 124 additions & 41 deletions graphlang/graphlang.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ import (
"github.com/smacker/go-tree-sitter/yaml"
)

// NodeColors defines basic colors for node types used when creating nodes
var NodeColors = map[string]string{
"Root": "orange",
"Namespace": "#f5a442",
"Type": "#4287f5",
"Function": "#42f54e",
}

// CallRelation struct to store pending function call relationships
type CallRelation struct {
CallerName string
Expand All @@ -63,23 +71,44 @@ type TreeSitterParser struct {
language *sitter.Language
code string
ext string
filePath string
currentFunc string
currentNamespace string
currentType string
driver neo4j.DriverWithContext
tree *sitter.Tree

rootName string
baseURL string
projectRoot string
rootID string

// Pending call relationships
pendingRelations []CallRelation
pendingMutex sync.Mutex
}

// NewTreeSitterParser initializes the TreeSitterParser and connects it to Neo4j.
func NewTreeSitterParser(driver neo4j.DriverWithContext) *TreeSitterParser {
// NewTreeSitterParser initializes the TreeSitterParser with configuration
func NewTreeSitterParser(driver neo4j.DriverWithContext, rootName, baseURL, projectRoot string) *TreeSitterParser {
return &TreeSitterParser{
Handle: sitter.NewParser(),
driver: driver,
Handle: sitter.NewParser(),
driver: driver,
rootName: rootName,
baseURL: baseURL,
projectRoot: projectRoot,
rootID: fmt.Sprintf("root:%s", rootName),
}
}

// createURL builds a link to the source code line using BaseURL and project root
func (parser *TreeSitterParser) createURL(line int) string {
rel := strings.TrimPrefix(parser.filePath, parser.projectRoot)
base := parser.baseURL
if !strings.HasSuffix(base, "/") {
base += "/"
}
return fmt.Sprintf("%s%s#%d", base, strings.TrimPrefix(rel, "/"), line)
}
Comment on lines +105 to 112
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

createURL breaks on Windows paths and needs URL-escaping

strings.TrimPrefix(parser.filePath, parser.projectRoot) leaves back-slashes on
Windows and doesn’t encode #, spaces, etc. Use filepath.ToSlash,
path.Join, and url.PathEscape (from net/url) to generate a portable,
clickable link.

 import (
     "context"
@@
+    "net/url"
     "fmt"
@@
-func (parser *TreeSitterParser) createURL(line int) string {
-    rel := strings.TrimPrefix(parser.filePath, parser.projectRoot)
-    base := parser.baseURL
-    if !strings.HasSuffix(base, "/") {
-        base += "/"
-    }
-    return fmt.Sprintf("%s%s#%d", base, strings.TrimPrefix(rel, "/"), line)
+func (parser *TreeSitterParser) createURL(line int) string {
+    rel, _ := filepath.Rel(parser.projectRoot, parser.filePath)
+    rel = filepath.ToSlash(rel)
+    escaped := url.PathEscape(rel)
+    return fmt.Sprintf("%s/%s#L%d", strings.TrimRight(parser.baseURL, "/"), escaped, line)
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In graphlang/graphlang.go around lines 105 to 112, the createURL function does
not handle Windows paths correctly and fails to URL-escape special characters.
To fix this, convert the relative file path to use forward slashes with
filepath.ToSlash, join the base URL and relative path using path.Join for proper
URL path construction, and apply url.PathEscape to the path and line fragment to
ensure all special characters are correctly escaped, producing a portable and
clickable URL.


// ExtToLang sets the parser's language based on the file extension.
Expand Down Expand Up @@ -259,9 +288,12 @@ func (parser *TreeSitterParser) traverseNode(node *sitter.Node, tx neo4j.Managed

// cleanup handles database cleanup
func (parser *TreeSitterParser) cleanup() error {
neo4jURI := "bolt://host.docker.internal:7687"
neo4jUser := "neo4j"
neo4jPassword := "securepassword"
neo4jURI := os.Getenv("NEO4J_URI")
neo4jUser := os.Getenv("NEO4J_USER")
neo4jPassword := os.Getenv("NEO4J_PASSWORD")
if neo4jURI == "" || neo4jUser == "" || neo4jPassword == "" {
return fmt.Errorf("missing required environment variables: NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD")
}

log.Printf("Connecting to Neo4j at %s with user %s and password %s\n", neo4jURI, neo4jUser, neo4jPassword)
driver, err := neo4j.NewDriverWithContext(neo4jURI, neo4j.BasicAuth(neo4jUser, neo4jPassword, ""))
Expand All @@ -282,8 +314,8 @@ func (parser *TreeSitterParser) cleanup() error {

_, err = tx1.Run(
context.Background(),
"MATCH (n) DETACH DELETE n",
map[string]any{},
"MATCH (n {project: $project}) DETACH DELETE n",
map[string]any{"project": parser.rootName},
)
if err != nil {
_ = tx1.Rollback(context.Background())
Expand Down Expand Up @@ -338,6 +370,18 @@ func (parser *TreeSitterParser) AnalyzeDirectory(dirPath string) error {
log.Printf("Database cleanup completed successfully")
}

// Create or update root node
// Create or update root node
session := parser.driver.NewSession(context.Background(), neo4j.SessionConfig{AccessMode: neo4j.AccessModeWrite})
defer session.Close(context.Background()) // Ensure session is closed
_, err := session.Run(context.Background(),
"MERGE (r:Root {id:$id}) ON CREATE SET r.name=$name, r.project=$project, r.color=$color",
map[string]any{"id": parser.rootID, "name": parser.rootName, "project": parser.rootName, "color": NodeColors["Root"]})
if err != nil {
return fmt.Errorf("failed to create root node: %v", err)
}
}

// Initialize analysis state
var (
processedFiles atomic.Int32
Expand Down Expand Up @@ -518,10 +562,15 @@ func (parser *TreeSitterParser) processFile(ctx context.Context, path string) er

// Create a new parser instance for this file
fileParser := &TreeSitterParser{
Handle: sitter.NewParser(),
driver: parser.driver,
code: string(code),
ext: filepath.Ext(path)[1:], // This gets the extension without the dot
Handle: sitter.NewParser(),
driver: parser.driver,
code: string(code),
ext: filepath.Ext(path)[1:], // This gets the extension without the dot
filePath: path,
rootName: parser.rootName,
baseURL: parser.baseURL,
projectRoot: parser.projectRoot,
rootID: parser.rootID,
}

// Parse and analyze the file
Expand Down Expand Up @@ -608,25 +657,32 @@ func (parser *TreeSitterParser) handleFunction(node *sitter.Node, tx neo4j.Manag
params := parser.extractParameters(node)
returnType := parser.extractReturnType(node)

line := int(position.Row) + 1
_, err := tx.Run(
context.Background(),
`MERGE (f:Function {id: $id})
ON CREATE SET
f.name = $name,
f.qualifiedName = $qualifiedName,
f.file = $file,
f.position = $position,
f.parameters = $params,
f.returnType = $returnType
WITH f
OPTIONAL MATCH (t:Type {qualifiedName: $typeName})
WHERE $typeName IS NOT NULL
MERGE (t)-[:HAS_MEMBER]->(f)`,
ON CREATE SET
f.name = $name,
f.qualifiedName = $qualifiedName,
f.file = $file,
f.project = $project,
f.color = $color,
f.url = $url,
f.position = $position,
f.parameters = $params,
f.returnType = $returnType
WITH f
OPTIONAL MATCH (t:Type {qualifiedName: $typeName})
WHERE $typeName IS NOT NULL
MERGE (t)-[:HAS_MEMBER]->(f)`,
map[string]any{
"id": fmt.Sprintf("%s:%s", parser.ext, qualifiedName),
"id": fmt.Sprintf("%s:%s", parser.rootName, qualifiedName),
"name": funcName,
"qualifiedName": qualifiedName,
"file": parser.ext,
"file": parser.filePath,
"project": parser.rootName,
"color": NodeColors["Function"],
"url": parser.createURL(line),
"position": fmt.Sprintf("%d:%d", position.Row, position.Column),
"params": params,
"returnType": returnType,
Expand All @@ -636,6 +692,9 @@ func (parser *TreeSitterParser) handleFunction(node *sitter.Node, tx neo4j.Manag

if err == nil {
parser.currentFunc = qualifiedName
_, _ = tx.Run(context.Background(),
`MATCH (r:Root {id:$rid}), (f:Function {id:$fid}) MERGE (r)-[:CONTAINS]->(f)`,
map[string]any{"rid": parser.rootID, "fid": fmt.Sprintf("%s:%s", parser.rootName, qualifiedName)})
Comment on lines +695 to +697
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The error returned by tx.Run when attempting to merge the CONTAINS relationship between the Root node and the Function node is currently ignored (using _, _ = ...). If this operation fails, the function node might not be correctly linked to its project root, which could lead to inconsistencies or issues in graph traversal later. Should this error be checked and potentially logged or even propagated to ensure the transaction rolls back if the linking fails?

_, linkErr := tx.Run(context.Background(),
			`MATCH (r:Root {id:$rid}), (f:Function {id:$fid}) MERGE (r)-[:CONTAINS]->(f)`,
			map[string]any{"rid": parser.rootID, "fid": fmt.Sprintf("%s:%s", parser.rootName, qualifiedName)})
		if linkErr != nil {
			log.Printf("Error linking function '%s' to root '%s': %v", qualifiedName, parser.rootID, linkErr)
			// Consider returning linkErr to ensure transaction rollback if this link is critical
			return linkErr
		}

log.Printf("Info: Set currentFunc to '%s'", qualifiedName)
} else {
log.Printf("Error handling function '%s': %v", qualifiedName, err)
Expand Down Expand Up @@ -867,18 +926,30 @@ func (parser *TreeSitterParser) handleNamespace(node *sitter.Node, tx neo4j.Mana
namespaceName := nameNode.Content([]byte(parser.code))
parser.currentNamespace = namespaceName

line := int(node.StartPoint().Row) + 1
_, err := tx.Run(
context.Background(),
`MERGE (n:Namespace {id: $id})
ON CREATE SET
n.name = $name,
n.file = $file`,
ON CREATE SET
n.name = $name,
n.file = $file,
n.project = $project,
n.color = $color,
n.url = $url`,
map[string]any{
"id": fmt.Sprintf("%s:%s", parser.ext, namespaceName),
"name": namespaceName,
"file": parser.ext,
"id": fmt.Sprintf("%s:%s", parser.rootName, namespaceName),
"name": namespaceName,
"file": parser.filePath,
"project": parser.rootName,
"color": NodeColors["Namespace"],
"url": parser.createURL(line),
},
)
if err == nil {
_, _ = tx.Run(context.Background(),
`MATCH (r:Root {id:$rid}), (n:Namespace {id:$nid}) MERGE (r)-[:CONTAINS]->(n)`,
map[string]any{"rid": parser.rootID, "nid": fmt.Sprintf("%s:%s", parser.rootName, namespaceName)})
Comment on lines +949 to +951
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the handleFunction case, the error from tx.Run when linking the Namespace node to the Root node is ignored. This could lead to a Namespace node existing without being properly connected to its project's Root. Would it be better to check this error and handle it, perhaps by logging and/or returning it to ensure transactional integrity?

_, linkErr := tx.Run(context.Background(),
			`MATCH (r:Root {id:$rid}), (n:Namespace {id:$nid}) MERGE (r)-[:CONTAINS]->(n)`,
			map[string]any{"rid": parser.rootID, "nid": fmt.Sprintf("%s:%s", parser.rootName, namespaceName)})
		if linkErr != nil {
			log.Printf("Error linking namespace '%s' to root '%s': %v", namespaceName, parser.rootID, linkErr)
			// Consider returning linkErr
			return linkErr
		}

}
return err
}

Expand All @@ -895,25 +966,37 @@ func (parser *TreeSitterParser) handleType(node *sitter.Node, tx neo4j.ManagedTr
}
parser.currentType = qualifiedName

line := int(node.StartPoint().Row) + 1
_, err := tx.Run(
context.Background(),
`MERGE (t:Type {id: $id})
ON CREATE SET
t.name = $name,
t.qualifiedName = $qualifiedName,
t.file = $file
WITH t
OPTIONAL MATCH (n:Namespace {name: $namespace})
WHERE $namespace IS NOT NULL
MERGE (n)-[:CONTAINS]->(t)`,
ON CREATE SET
t.name = $name,
t.qualifiedName = $qualifiedName,
t.file = $file,
t.project = $project,
t.color = $color,
t.url = $url
WITH t
OPTIONAL MATCH (n:Namespace {name: $namespace})
WHERE $namespace IS NOT NULL
MERGE (n)-[:CONTAINS]->(t)`,
map[string]any{
"id": fmt.Sprintf("%s:%s", parser.ext, qualifiedName),
"id": fmt.Sprintf("%s:%s", parser.rootName, qualifiedName),
"name": typeName,
"qualifiedName": qualifiedName,
"file": parser.ext,
"file": parser.filePath,
"project": parser.rootName,
"color": NodeColors["Type"],
"url": parser.createURL(line),
"namespace": parser.currentNamespace,
},
)
if err == nil {
_, _ = tx.Run(context.Background(),
`MATCH (r:Root {id:$rid}), (t:Type {id:$tid}) MERGE (r)-[:CONTAINS]->(t)`,
map[string]any{"rid": parser.rootID, "tid": fmt.Sprintf("%s:%s", parser.rootName, qualifiedName)})
Comment on lines +996 to +998
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The error from tx.Run when linking the Type node to the Root node is also ignored here. Consistent with the feedback for handleFunction and handleNamespace, it's advisable to check this error. If the link fails, it could impact graph consistency. Should this error be logged and possibly returned?

_, linkErr := tx.Run(context.Background(),
			`MATCH (r:Root {id:$rid}), (t:Type {id:$tid}) MERGE (r)-[:CONTAINS]->(t)`,
			map[string]any{"rid": parser.rootID, "tid": fmt.Sprintf("%s:%s", parser.rootName, qualifiedName)})
		if linkErr != nil {
			log.Printf("Error linking type '%s' to root '%s': %v", qualifiedName, parser.rootID, linkErr)
			// Consider returning linkErr
			return linkErr
		}

}
return err
}

Expand Down
25 changes: 17 additions & 8 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,28 @@ package main
import (
"context"
"log"
"os"

"github.com/neo4j/neo4j-go-driver/v5/neo4j"
"github.com/theapemachine/platform-graph/graphlang"
)

// Main function to demonstrate usage.
func main() {
neo4jURI := "bolt://host.docker.internal:7687"
neo4jUser := "neo4j"
neo4jPassword := "securepassword"
neo4jURI := os.Getenv("NEO4J_URI")
neo4jUser := os.Getenv("NEO4J_USER")
neo4jPassword := os.Getenv("NEO4J_PASSWORD")
rootName := os.Getenv("ROOT_NAME")
baseURL := os.Getenv("BASE_URL")
if neo4jURI == "" || neo4jUser == "" || neo4jPassword == "" {
log.Fatal("NEO4J_URI, NEO4J_USER and NEO4J_PASSWORD must be set")
}
if rootName == "" {
rootName = "UnknownRoot"
}
if baseURL == "" {
baseURL = "http://localhost"
}

log.Printf("Connecting to Neo4j at %s with user %s and password %s\n", neo4jURI, neo4jUser, neo4jPassword)
driver, err := neo4j.NewDriverWithContext(neo4jURI, neo4j.BasicAuth(neo4jUser, neo4jPassword, ""))
Expand All @@ -21,11 +33,8 @@ func main() {
}
defer driver.Close(context.Background())

session := driver.NewSession(context.Background(), neo4j.SessionConfig{AccessMode: neo4j.AccessModeWrite})
defer session.Close(context.Background())

dirPath := "/app" // Replace with the actual directory path
dirPath := "/app"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The dirPath is hardcoded to "/app". While this might be suitable for a specific Docker deployment, making it configurable (e.g., via an environment variable like ANALYSIS_PATH) would offer more flexibility for running the analyzer in different environments or against different source directories without rebuilding the container or changing the code. Consider adding an environment variable for this path with "/app" as a default.


parser := graphlang.NewTreeSitterParser(driver)
parser := graphlang.NewTreeSitterParser(driver, rootName, baseURL, dirPath)
parser.AnalyzeDirectory(dirPath)
}