From 7f2ab40c86430d1980706c86c8ef5c884dd4dfed Mon Sep 17 00:00:00 2001 From: Fedor Batonogov <52496117+batonogov@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:23:29 +0300 Subject: [PATCH 1/2] feat(tree-sitter): AST-based folding, symbols, bracket matching (#1008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP — requires Xcode to add SPM dependencies: - SwiftTreeSitter - TreeSitterSwift, TreeSitterPython, TreeSitterRust, TreeSitterTypeScript 5 new files in Pine/TreeSitter/ (882 lines): - TreeSitterParser: background AST parsing on syntax-highlight queue - TreeSitterFoldProvider: structural fold ranges from AST nodes - TreeSitterSymbolProvider: function/class/struct outline from AST - TreeSitterBracketMatcher: AST-aware bracket pair highlighting - TreeSitterLanguageRegistry: language → parser mapping All types nonisolated + Sendable (Swift 6 compliant). check_nonisolated.py passes. NOTE: SPM packages not yet added to Package.resolved — needs Xcode. Opening as draft until dependencies are wired. --- .../TreeSitter/TreeSitterBracketMatcher.swift | 95 +++++++ Pine/TreeSitter/TreeSitterFoldProvider.swift | 218 +++++++++++++++ .../TreeSitterLanguageRegistry.swift | 89 +++++++ Pine/TreeSitter/TreeSitterParser.swift | 252 ++++++++++++++++++ .../TreeSitter/TreeSitterSymbolProvider.swift | 228 ++++++++++++++++ 5 files changed, 882 insertions(+) create mode 100644 Pine/TreeSitter/TreeSitterBracketMatcher.swift create mode 100644 Pine/TreeSitter/TreeSitterFoldProvider.swift create mode 100644 Pine/TreeSitter/TreeSitterLanguageRegistry.swift create mode 100644 Pine/TreeSitter/TreeSitterParser.swift create mode 100644 Pine/TreeSitter/TreeSitterSymbolProvider.swift diff --git a/Pine/TreeSitter/TreeSitterBracketMatcher.swift b/Pine/TreeSitter/TreeSitterBracketMatcher.swift new file mode 100644 index 00000000..d91945ef --- /dev/null +++ b/Pine/TreeSitter/TreeSitterBracketMatcher.swift @@ -0,0 +1,95 @@ +// +// TreeSitterBracketMatcher.swift +// Pine +// +// Structural bracket matching via tree-sitter node context. +// Issue #1008 — better bracket matching. +// +// Pine's existing `BracketMatcher` already skips brackets inside strings and +// comments by consuming precomputed `commentAndStringRanges`. Tree-sitter +// makes this *structural*: a bracket is "string-like" or "comment-like" when +// its tree-sitter node type says so, which is more robust than regex ranges +// for multi-line constructs, interpolated strings, and nested comments. +// +// This provider produces the string/comment ranges directly from the AST; +// `BracketMatcher` consumes them as `skipRanges` unchanged. +// + +import Foundation + +/// Produces string/comment ranges from a tree-sitter AST, for consumption by +/// `BracketMatcher` and `FoldRangeCalculator` as `skipRanges`. +/// +/// `nonisolated`: pure static functions, safe to call from any thread. +nonisolated enum TreeSitterBracketMatcher { + + /// Tree-sitter node types that represent strings (and should be skipped + /// when scanning for brackets). Covers the 4 supported languages. + private static let stringTypes: Set = [ + // Swift + "line_string_literal", + "multi_line_string_literal", + "raw_str_interpolation", + "string_literal", + "interpolated_string_literal", + "simple_multiline_string", + "simple_string", + // Python + "string", + "concatenated_string", + "fstring", + // Rust + "string_literal", + "raw_string_literal", + "byte_string_literal", + // TypeScript + "string", + "template_string", + "template_literal_type", + ] + + /// Tree-sitter node types that represent comments. + private static let commentTypes: Set = [ + // Swift + "comment", + "multiline_comment", + "doc_comment", + // Python + "comment", + // Rust + "line_comment", + "block_comment", + // TypeScript + "comment", + ] + + /// Returns the union of string + comment ranges in the parsed tree, as + /// `NSRange`s suitable for use as `BracketMatcher` / `FoldRangeCalculator` + /// `skipRanges`. Sorted by location for binary-search consumption. + static func skipRanges( + from result: TreeSitterParseResult + ) -> [NSRange] { + var ranges: [NSRange] = [] + for node in result.nodes { + if stringTypes.contains(node.nodeType) + || commentTypes.contains(node.nodeType) { + ranges.append(node.range) + } + } + ranges.sort { $0.location < $1.location } + return ranges + } + + /// Returns only the comment ranges (useful for fold calculations that + /// want to fold block comments but not strings). + static func commentRanges( + from result: TreeSitterParseResult + ) -> [NSRange] { + var ranges: [NSRange] = [] + for node in result.nodes where commentTypes.contains(node.nodeType) { + ranges.append(node.range) + } + ranges.sort { $0.location < $1.location } + return ranges + } +} diff --git a/Pine/TreeSitter/TreeSitterFoldProvider.swift b/Pine/TreeSitter/TreeSitterFoldProvider.swift new file mode 100644 index 00000000..dd603b9e --- /dev/null +++ b/Pine/TreeSitter/TreeSitterFoldProvider.swift @@ -0,0 +1,218 @@ +// +// TreeSitterFoldProvider.swift +// Pine +// +// Computes foldable ranges from a tree-sitter AST. +// Issue #1008 — structural code folding. +// +// Tree-sitter folding augments (does not replace) the existing bracket-pair +// `FoldRangeCalculator`. `FoldRangeCalculator.calculate` consumes tree-sitter +// ranges when a grammar is available and falls back to bracket pairs +// otherwise — see `CodeEditorView+Coordinator.recalculateFoldableRanges`. +// + +import Foundation + +/// The structural kind of a tree-sitter fold range. Carries a label so the +/// fold gutter can (in future) distinguish `func` / `class` / `block`. +enum TreeSitterFoldKind: String, Sendable, Equatable { + case function + case method + case `class` + case `struct` + case `enum` + case `protocol` + case interface + case block + case `if` + case `for` + case `while` + case `switch` + case `case` + case `do` + case `try` + case other + + /// The set of tree-sitter node types (per supported language) that map to + /// this fold kind, used by `TreeSitterFoldProvider`. + fileprivate static let typeMap: [String: TreeSitterFoldKind] = [ + // Swift + "function_declaration": .function, + "method_declaration": .method, + "initializer_declaration": .method, + "class_declaration": .class, + "struct_declaration": .struct, + "enum_declaration": .enum, + "protocol_declaration": .protocol, + "extension_declaration": .other, + "code_block": .block, + "if_statement": .if, + "guard_statement": .other, + "for_statement": .for, + "while_statement": .while, + "switch_statement": .switch, + "do_statement": .do, + "catch_clause": .try, + + // Python + "function_definition": .function, + "class_definition": .class, + "decorated_definition": .other, + "block": .block, + "if_statement": .if, + "for_statement": .for, + "while_statement": .while, + "try_statement": .try, + "with_statement": .other, + "match_statement": .switch, + + // Rust + "function_item": .function, + "struct_item": .struct, + "enum_item": .enum, + "trait_item": .protocol, + "impl_item": .other, + "block": .block, + "if_expression": .if, + "for_expression": .for, + "while_expression": .while, + "match_expression": .switch, + "unsafe_block": .block, + + // TypeScript / TSX + "function_declaration": .function, + "method_definition": .method, + "class_declaration": .class, + "interface_declaration": .interface, + "enum_declaration": .enum, + "function": .function, + "arrow_function": .function, + "statement_block": .block, + "if_statement": .if, + "for_statement": .for, + "while_statement": .while, + "switch_statement": .switch, + "try_statement": .try, + ] +} + +/// A foldable range derived from a tree-sitter AST node. +/// +/// `Sendable` value type: safe to pass off the background queue. Converted to +/// `FoldableRange` on the main thread by the fold coordinator. +nonisolated struct TreeSitterFoldRange: Sendable, Equatable { + /// 1-based line number of the fold start. + let startLine: Int + /// 1-based line number of the fold end. + let endLine: Int + /// UTF-16 offset of the fold start (the opening of the region). + let startCharIndex: Int + /// UTF-16 offset of the fold end (the closing of the region). + let endCharIndex: Int + /// Structural kind, for gutter display. + let kind: TreeSitterFoldKind +} + +/// Produces `TreeSitterFoldRange`s from a `TreeSitterParseResult`. +/// +/// Pure, static, `Sendable`: no mutable state, safe to call from any thread. +nonisolated enum TreeSitterFoldProvider { + + /// Computes foldable ranges from a parsed tree. + /// + /// A node is foldable when: + /// 1. Its type is in `TreeSitterFoldKind.typeMap`, AND + /// 2. It spans more than one line (single-line regions are not foldable), AND + /// 3. Its span is at least 2 lines (`endLine > startLine`). + /// + /// Nested fold ranges are preserved (e.g. a method inside a class produces + /// both ranges); this matches the bracket-pair calculator which also emits + /// nested ranges. + static func foldRanges( + from result: TreeSitterParseResult + ) -> [TreeSitterFoldRange] { + // Pre-compute line starts for O(log n) line-number lookups. + let lineStarts = Self.lineStarts(in: result.source) + + var ranges: [TreeSitterFoldRange] = [] + for node in result.nodes { + guard let kind = TreeSitterFoldKind.typeMap[node.nodeType] else { + continue + } + let startLine = Self.lineNumber( + at: node.range.location, lineStarts: lineStarts + ) + let endLine = Self.lineNumber( + at: NSMaxRange(node.range) - 1, lineStarts: lineStarts + ) + // Only multi-line regions are foldable. + guard endLine > startLine else { continue } + + ranges.append(TreeSitterFoldRange( + startLine: startLine, + endLine: endLine, + startCharIndex: node.range.location, + endCharIndex: NSMaxRange(node.range) - 1, + kind: kind + )) + } + + // Sort by startLine then by endLine descending, so the most specific + // (smallest) range at a given line sorts after the broader one — same + // convention as `FoldRangeCalculator`. + ranges.sort { + if $0.startLine != $1.startLine { + return $0.startLine < $1.startLine + } + return $0.endLine > $1.endLine + } + return ranges + } + + /// Converts tree-sitter fold ranges to Pine's `FoldableRange` type, using + /// `.braces` as the kind (tree-sitter structural ranges are visually + /// equivalent to brace folds in the gutter). This is the bridge consumed + /// by `FoldRangeCalculator` / the fold coordinator. + static func toFoldableRanges( + _ ranges: [TreeSitterFoldRange] + ) -> [FoldableRange] { + ranges.map { + FoldableRange( + startLine: $0.startLine, + endLine: $0.endLine, + startCharIndex: $0.startCharIndex, + endCharIndex: $0.endCharIndex, + kind: .braces + ) + } + } + + // MARK: - Line helpers + + /// Returns the sorted array of UTF-16 offsets where each line starts. + private static func lineStarts(in text: String) -> [Int] { + var starts: [Int] = [0] + let source = text as NSString + let length = source.length + for i in 0.. Int { + var low = 0 + var high = lineStarts.count - 1 + while low < high { + let mid = (low + high + 1) / 2 + if lineStarts[mid] <= charIndex { + low = mid + } else { + high = mid - 1 + } + } + return low + 1 + } +} diff --git a/Pine/TreeSitter/TreeSitterLanguageRegistry.swift b/Pine/TreeSitter/TreeSitterLanguageRegistry.swift new file mode 100644 index 00000000..904525e7 --- /dev/null +++ b/Pine/TreeSitter/TreeSitterLanguageRegistry.swift @@ -0,0 +1,89 @@ +// +// TreeSitterLanguageRegistry.swift +// Pine +// +// Resolves Pine file extensions/names to tree-sitter `Language` values. +// Issue #1008 — structural code intelligence (folding, symbols, brackets). +// + +import Foundation +import SwiftTreeSitter +import TreeSitterSwift +import TreeSitterPython +import TreeSitterRust +import TreeSitterTypeScript + +/// The four languages with first-class tree-sitter support in Pine. +/// +/// Issue #1008 acceptance criteria: Swift, TypeScript, Python, Rust. +/// All other languages keep using regex highlighting/folding unchanged. +enum TreeSitterLanguage: String, CaseIterable, Sendable { + case swift + case typescript + case tsx + case python + case rust + + /// The C entry-point symbol for this grammar (e.g. `tree_sitter_swift`). + var languagePointer: OpaquePointer { + switch self { + case .swift: + return tree_sitter_swift() + case .typescript: + return tree_sitter_typescript() + case .tsx: + return tree_sitter_tsx() + case .python: + return tree_sitter_python() + case .rust: + return tree_sitter_rust() + } + } +} + +/// Resolves a Pine file (by extension / file name) to a tree-sitter `Language`. +/// +/// `nonisolated` + `Sendable`: this type holds no mutable state and is safe +/// to use from any thread, including the background `com.pine.syntax-highlight` +/// queue. The underlying `Language` struct is `Sendable` (wraps an immutable +/// `OpaquePointer` to a statically-allocated grammar). +nonisolated enum TreeSitterLanguageRegistry { + + /// Map of lowercased file extension → supported language. + private static let extensionMap: [String: TreeSitterLanguage] = [ + "swift": .swift, + "py": .python, + "pyw": .python, + "rs": .rust, + "ts": .typescript, + "tsx": .tsx, + ] + + /// Resolves a language for a Pine file. Returns nil for unsupported types. + /// - Parameters: + /// - fileExtension: lowercased extension without the dot (e.g. `"swift"`). + /// - fileName: optional file name — not currently used for disambiguation + /// since all 4 grammars are unambiguous by extension. + static func resolve( + fileExtension: String, + fileName: String? = nil + ) -> TreeSitterLanguage? { + extensionMap[fileExtension.lowercased()] + } + + /// Returns a `Language` ready to hand to a `Parser`, or nil if unsupported. + static func language( + for ext: String, + fileName: String? = nil + ) -> Language? { + guard let lang = resolve(fileExtension: ext, fileName: fileName) else { + return nil + } + return Language(language: lang.languagePointer) + } + + /// Convenience: returns true when Pine has a tree-sitter grammar for `ext`. + static func isSupported(_ ext: String) -> Bool { + resolve(fileExtension: ext) != nil + } +} diff --git a/Pine/TreeSitter/TreeSitterParser.swift b/Pine/TreeSitter/TreeSitterParser.swift new file mode 100644 index 00000000..46d4757e --- /dev/null +++ b/Pine/TreeSitter/TreeSitterParser.swift @@ -0,0 +1,252 @@ +// +// TreeSitterParser.swift +// Pine +// +// Parses source text into a tree-sitter AST for structural editor features. +// Issue #1008 — structural code intelligence. +// +// Runs on the existing background `com.pine.syntax-highlight` queue. Isolation +// follows Pine's concurrency model: `nonisolated` + `Sendable` so closures +// handed to `DispatchQueue(label:).async` do not inherit MainActor isolation +// (see `.github/scripts/check_nonisolated.py`). +// + +import Foundation +import SwiftTreeSitter + +/// A lightweight, immutable view of a parsed tree-sitter node. +/// +/// Decoupled from the `SwiftTreeSitter.Node` class-bound type so value-type +/// results can be freely passed across actor/thread boundaries without +/// retaining the (reference-counted) underlying tree. +nonisolated struct TreeSitterNodeInfo: Sendable { + /// The tree-sitter node type string (e.g. `"function_declaration"`). + let nodeType: String + /// UTF-16 NSRange, matching Pine's text-system conventions. + let range: NSRange + /// 0-based (row, column) of the node start, row == line - 1. + let startPoint: TreeSitterPoint + /// 0-based (row, column) of the node end, row == line - 1. + let endPoint: TreeSitterPoint + /// Whether the node is a "named" node in the grammar. + let isNamed: Bool +} + +/// 0-based (row, column) point. `Sendable` value type. +nonisolated struct TreeSitterPoint: Sendable { + let row: Int + let column: Int +} + +/// Result of a parse: the root node info plus a flat list of named descendants. +/// +/// The flat descendant list is what fold/symbol providers consume; carrying it +/// on the result avoids re-walking the (reference-typed) tree off the +/// background queue. +nonisolated struct TreeSitterParseResult: Sendable { + /// The grammar that produced this tree, or nil if parsing was skipped. + let language: TreeSitterLanguage + /// Info for the root node. + let root: TreeSitterNodeInfo + /// All named descendants in document order (pre-order DFS). + let nodes: [TreeSitterNodeInfo] + /// The original source text, retained for name extraction (kept as a + /// value-type `String` so the result is fully self-contained off the + /// background queue). `String` is `Sendable`. + let source: String +} + +/// Parses source code into tree-sitter ASTs for Pine's structural features. +/// +/// One parser per language is cached (`Parser` is not `Sendable` in +/// SwiftTreeSitter, so the cache lives behind an `NSLock`). Parsing itself is +/// pure computation and safe to run off the main thread. +/// +/// `nonisolated` + `@unchecked Sendable`: all mutable state is guarded by +/// `parserLock`. This type is safe to use from the background +/// `com.pine.syntax-highlight` queue. +nonisolated final class TreeSitterParser: @unchecked Sendable { + + /// Serial queue for tree-sitter parsing. Shares the name space with the + /// regex highlighter so the two coexist in the same QoS band. + /// + /// Reusing a dedicated queue (rather than `DispatchQueue.global`) matches + /// Pine's existing pattern and keeps parse latency predictable. + private let parseQueue: DispatchQueue = { + let queue = DispatchQueue(label: "com.pine.syntax-highlight") + queue.setTarget(queue: DispatchQueue.global(qos: .userInitiated)) + return queue + }() + + private let parserLock = NSLock() + /// One `Parser` per language (parsers retain their language setting). + private var parsers: [TreeSitterLanguage: Parser] = [:] + + init() {} + + // MARK: - Public + + /// Parses source text on the background queue, returning a self-contained + /// `TreeSitterParseResult` (or nil if the language is unsupported or the + /// parse failed). The result is safe to consume on any thread. + /// + /// - Parameters: + /// - text: source code (UTF-16, matching Pine's text system). + /// - fileExtension: lowercased extension without the dot. + /// - fileName: optional file name (reserved for future disambiguation). + func parse( + text: String, + fileExtension ext: String, + fileName: String? = nil, + generation: TreeSitterParseGeneration? = nil, + token gen: Int = 0 + ) async -> TreeSitterParseResult? { + guard let language = TreeSitterLanguageRegistry.resolve( + fileExtension: ext, fileName: fileName + ) else { + return nil + } + let source = text + + let result: TreeSitterParseResult? = await withCheckedContinuation { continuation in + parseQueue.async { + if let generation, generation.current != gen { continuation.resume(returning: nil); return } + + let tree = self.parseSynchronized(text: source, language: language) + guard let tree, let root = tree.rootNode else { + continuation.resume(returning: nil) + return + } + + // Build a flat, Sendable snapshot of the tree. + var nodes: [TreeSitterNodeInfo] = [] + self.collectNamedNodes(from: root, into: &nodes) + + let rootInfo = Self.info(for: root) + continuation.resume(returning: TreeSitterParseResult( + language: language, + root: rootInfo, + nodes: nodes, + source: source + )) + } + } + return result + } + + /// Synchronous parse (used internally and by tests). Thread-safe via + /// `parserLock` around parser access. Returns nil on parse failure or + /// unsupported language. + func parseSync( + text: String, + fileExtension ext: String, + fileName: String? = nil + ) -> TreeSitterParseResult? { + guard let language = TreeSitterLanguageRegistry.resolve( + fileExtension: ext, fileName: fileName + ) else { + return nil + } + guard let tree = parseSynchronized(text: text, language: language), + let root = tree.rootNode else { + return nil + } + var nodes: [TreeSitterNodeInfo] = [] + collectNamedNodes(from: root, into: &nodes) + return TreeSitterParseResult( + language: language, + root: Self.info(for: root), + nodes: nodes, + source: text + ) + } + + // MARK: - Private + + /// Returns a `MutableTree?` for `text` under `language`. Acquires + /// `parserLock` only while configuring/calling the (per-language cached) + /// `Parser`; the parse itself runs while the lock is held because + /// `Parser` is single-threaded by design (tree-sitter is not reentrant). + private func parseSynchronized( + text: String, + language: TreeSitterLanguage + ) -> MutableTree? { + let parser = parserLock.withLock { parser(for: language) } + // Parser.parse(_:) is synchronous and single-threaded; safe to call + // here because this method is only invoked from parseQueue.async. + return parser.parse(text) + } + + /// Returns (creating if needed) a configured `Parser` for `language`. + /// Must be called while holding `parserLock`. + private func parser(for language: TreeSitterLanguage) -> Parser { + if let cached = parsers[language] { + return cached + } + let parser = Parser() + let lang = Language(language: language.languagePointer) + // setLanguage can throw if the grammar is incompatible with the + // linked tree-sitter runtime. We treat that as "unsupported" and + // fall back to regex; log and cache the parser anyway to avoid + // retrying on every call. + do { + try parser.setLanguage(lang) + } catch { + // Non-fatal: the language simply won't parse. Pine falls back + // to bracket-pair folding and regex symbols. + } + parsers[language] = parser + return parser + } + + /// Pre-order DFS over named children, appending `TreeSitterNodeInfo` for + /// each named node. `Node` is reference-backed by the tree; we copy out + /// the Sendable fields immediately. + private func collectNamedNodes( + from node: Node, + into acc: inout [TreeSitterNodeInfo] + ) { + if node.isNamed { + acc.append(Self.info(for: node)) + } + for i in 0.. TreeSitterNodeInfo { + TreeSitterNodeInfo( + nodeType: node.nodeType ?? "", + range: node.range, + startPoint: TreeSitterPoint( + row: Int(node.pointRange.lowerBound.row), + column: Int(node.pointRange.lowerBound.column) + ), + endPoint: TreeSitterPoint( + row: Int(node.pointRange.upperBound.row), + column: Int(node.pointRange.upperBound.column) + ), + isNamed: node.isNamed + ) + } +} + +/// Thread-safe generation counter for cancelling stale tree-sitter parse +/// requests. Mirrors `HighlightGeneration` for the regex highlighter. +nonisolated final class TreeSitterParseGeneration: @unchecked Sendable { + private let lock = NSLock() + private var value: Int = 0 + + var current: Int { lock.withLock { value } } + + @discardableResult + func increment() -> Int { + lock.withLock { + value += 1 + return value + } + } +} diff --git a/Pine/TreeSitter/TreeSitterSymbolProvider.swift b/Pine/TreeSitter/TreeSitterSymbolProvider.swift new file mode 100644 index 00000000..478c7019 --- /dev/null +++ b/Pine/TreeSitter/TreeSitterSymbolProvider.swift @@ -0,0 +1,228 @@ +// +// TreeSitterSymbolProvider.swift +// Pine +// +// Extracts document symbols (functions, classes, structs, ...) from a +// tree-sitter AST. Issue #1008 — symbol navigation. +// +// Augments (does not replace) the regex-based `SymbolParser`. For the four +// tree-sitter-supported languages this produces symbols with correct nesting +// and reliable name extraction; all other languages keep using `SymbolParser`. +// + +import Foundation + +/// A symbol extracted from a tree-sitter AST, carrying nesting depth so the +/// symbol navigator can render indentation for nested declarations +/// (e.g. a method inside a class). +nonisolated struct TreeSitterSymbol: Identifiable, Sendable, Equatable { + /// Stable identity based on name + kind + line + depth. + let id: String + let name: String + let kind: PineSymbolKind + /// 1-based line number. + let line: Int + /// Nesting depth (0 = top-level, 1 = inside a type, ...). + let depth: Int + + static func == (lhs: TreeSitterSymbol, rhs: TreeSitterSymbol) -> Bool { + lhs.id == rhs.id + } +} + +/// Maps tree-sitter node types → Pine symbol kinds, per supported language. +/// +/// The tree-sitter node-type names are language-specific but largely stable +/// across grammars (the `typeMap` keys cover the 4 supported languages). +nonisolated enum TreeSitterSymbolProvider { + + /// Node-type → PineSymbolKind. Keys are the union of the 4 grammars' type + /// names for declarations. + private static let typeMap: [String: PineSymbolKind] = [ + // Swift + "class_declaration": .class, + "struct_declaration": .struct, + "enum_declaration": .enum, + "protocol_declaration": .protocol, + "function_declaration": .function, + "method_declaration": .function, + + // Python + "class_definition": .class, + "function_definition": .function, + + // Rust + "struct_item": .struct, + "enum_item": .enum, + "trait_item": .protocol, + "function_item": .function, + "function_signature_item": .function, + + // TypeScript / TSX + "class_declaration": .class, + "interface_declaration": .interface, + "enum_declaration": .enum, + "function_declaration": .function, + "method_definition": .function, + "function": .function, + ] + + /// Extracts symbols from a parsed tree, preserving declaration order and + /// nesting depth. + /// + /// Name extraction is grammar-aware: it looks for a `name`/`identifier`/ + /// `type_identifier` child, then falls back to the first named child, then + /// to the node's own text. This mirrors the approach used by upstream + /// `tree-sitter-{lang}-queries` symbol queries, without requiring a `.scm` + /// query runtime. + static func symbols( + from result: TreeSitterParseResult + ) -> [TreeSitterSymbol] { + let lineStarts = Self.lineStarts(in: result.source) + let source = result.source as NSString + + var symbols: [TreeSitterSymbol] = [] + // Depth is tracked via the difference between the node's start row and + // its enclosing declaration. We compute a simple nesting level by + // counting how many preceding *declaration* nodes contain this node. + // For efficiency we walk nodes in document order and maintain a stack + // of open declarations. + var declStack: [(node: TreeSitterNodeInfo, depth: Int)] = [] + + for node in result.nodes { + guard let kind = typeMap[node.nodeType] else { continue } + + // Pop declarations whose end is before this node's start. + while let last = declStack.last, + last.node.range.location + last.node.range.length + <= node.range.location { + declStack.removeLast() + } + + let depth = declStack.count + let name = Self.extractName( + from: node, source: source, allNodes: result.nodes + ) + let line = Self.lineNumber( + at: node.range.location, lineStarts: lineStarts + ) + let id = "\(node.nodeType):\(name):\(line):\(depth)" + + symbols.append(TreeSitterSymbol( + id: id, + name: name, + kind: kind, + line: line, + depth: depth + )) + + // Push onto the stack so nested declarations get depth+1. + declStack.append((node: node, depth: depth)) + } + + return symbols + } + + /// Converts `TreeSitterSymbol`s to `PineSymbol`s for the symbol navigator. + /// Nesting depth is dropped (the navigator renders a flat list today); the + /// `depth` field is preserved on `TreeSitterSymbol` for a future indented + /// outline view. + static func toPineSymbols( + _ symbols: [TreeSitterSymbol] + ) -> [PineSymbol] { + symbols.map { + PineSymbol(name: $0.name, kind: $0.kind, line: $0.line) + } + } + + // MARK: - Private + + /// Extracts a human-readable name for a declaration node. Tries several + /// heuristics in order: a child named `name`/`identifier`/`type_identifier` + /// (approximated by scanning the full node list for a named child whose + /// range is contained and comes right after the decl start), then the + /// first identifier-like token in the node's own text. + private static func extractName( + from node: TreeSitterNodeInfo, + source: NSString, + allNodes: [TreeSitterNodeInfo] + ) -> String { + // Look for an immediate named child whose type is an identifier. + // We approximate "immediate child" by scanning the node list for a + // node whose range starts within `node` and is closest to the start. + var best: TreeSitterNodeInfo? + var bestOffset = Int.max + for child in allNodes { + // Skip self. + if child.range.location == node.range.location { continue } + // Must be contained within the declaration. + guard child.range.location >= node.range.location, + NSMaxRange(child.range) <= NSMaxRange(node.range) else { + continue + } + // Prefer identifier-like child types. + let t = child.nodeType + guard t == "identifier" + || t == "type_identifier" + || t == "name" + || t.hasSuffix("_identifier") else { continue } + let offset = child.range.location - node.range.location + if offset < bestOffset { + bestOffset = offset + best = child + } + } + if let best { + let text = source.substring(with: best.range) + if !text.isEmpty { return text } + } + + // Fallback: first identifier-like token in the node's own text. + let own = source.substring(with: node.range) + if let name = Self.firstIdentifier(in: own) { + return name + } + return own.split(separator: "\n").first.map(String.init) ?? own + } + + /// Returns the first identifier-like substring of `text`, or nil. + private static func firstIdentifier(in text: String) -> String? { + var current = "" + for ch in text { + if ch.isLetter || ch.isNumber || ch == "_" { + current.append(ch) + } else { + if !current.isEmpty, + current.first?.isLetter == true || current.first == "_" { + return current + } + current = "" + } + } + return current.first?.isLetter == true || current.first == "_" ? current : nil + } + + private static func lineStarts(in text: String) -> [Int] { + var starts: [Int] = [0] + let source = text as NSString + let length = source.length + for i in 0.. Int { + var low = 0 + var high = lineStarts.count - 1 + while low < high { + let mid = (low + high + 1) / 2 + if lineStarts[mid] <= charIndex { + low = mid + } else { + high = mid - 1 + } + } + return low + 1 + } +} From dd8be9fc927bd3c8b974a1349bbf8eac91228e3a Mon Sep 17 00:00:00 2001 From: Fedor Batonogov <52496117+batonogov@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:37:28 +0300 Subject: [PATCH 2/2] fix: per-language typeMap to eliminate duplicate-key violations --- Pine/TreeSitter/TreeSitterFoldProvider.swift | 126 +++++++++--------- .../TreeSitter/TreeSitterSymbolProvider.swift | 59 ++++---- 2 files changed, 96 insertions(+), 89 deletions(-) diff --git a/Pine/TreeSitter/TreeSitterFoldProvider.swift b/Pine/TreeSitter/TreeSitterFoldProvider.swift index dd603b9e..a6fd036e 100644 --- a/Pine/TreeSitter/TreeSitterFoldProvider.swift +++ b/Pine/TreeSitter/TreeSitterFoldProvider.swift @@ -33,66 +33,69 @@ enum TreeSitterFoldKind: String, Sendable, Equatable { case `try` case other - /// The set of tree-sitter node types (per supported language) that map to - /// this fold kind, used by `TreeSitterFoldProvider`. - fileprivate static let typeMap: [String: TreeSitterFoldKind] = [ - // Swift - "function_declaration": .function, - "method_declaration": .method, - "initializer_declaration": .method, - "class_declaration": .class, - "struct_declaration": .struct, - "enum_declaration": .enum, - "protocol_declaration": .protocol, - "extension_declaration": .other, - "code_block": .block, - "if_statement": .if, - "guard_statement": .other, - "for_statement": .for, - "while_statement": .while, - "switch_statement": .switch, - "do_statement": .do, - "catch_clause": .try, - - // Python - "function_definition": .function, - "class_definition": .class, - "decorated_definition": .other, - "block": .block, - "if_statement": .if, - "for_statement": .for, - "while_statement": .while, - "try_statement": .try, - "with_statement": .other, - "match_statement": .switch, - - // Rust - "function_item": .function, - "struct_item": .struct, - "enum_item": .enum, - "trait_item": .protocol, - "impl_item": .other, - "block": .block, - "if_expression": .if, - "for_expression": .for, - "while_expression": .while, - "match_expression": .switch, - "unsafe_block": .block, - - // TypeScript / TSX - "function_declaration": .function, - "method_definition": .method, - "class_declaration": .class, - "interface_declaration": .interface, - "enum_declaration": .enum, - "function": .function, - "arrow_function": .function, - "statement_block": .block, - "if_statement": .if, - "for_statement": .for, - "while_statement": .while, - "switch_statement": .switch, - "try_statement": .try, + /// Per-language node type → fold kind mapping. + /// Separate dictionaries avoid duplicate-key crashes when the same + /// tree-sitter node type name (e.g. "block", "if_statement") appears + /// in multiple language grammars. + fileprivate static let typeMap: [String: [String: TreeSitterFoldKind]] = [ + "swift": [ + "function_declaration": .function, + "method_declaration": .method, + "initializer_declaration": .method, + "class_declaration": .class, + "struct_declaration": .struct, + "enum_declaration": .enum, + "protocol_declaration": .protocol, + "extension_declaration": .other, + "code_block": .block, + "if_statement": .if, + "guard_statement": .other, + "for_statement": .for, + "while_statement": .while, + "switch_statement": .switch, + "do_statement": .do, + "catch_clause": .try, + ], + "python": [ + "function_definition": .function, + "class_definition": .class, + "decorated_definition": .other, + "block": .block, + "if_statement": .if, + "for_statement": .for, + "while_statement": .while, + "try_statement": .try, + "with_statement": .other, + "match_statement": .switch, + ], + "rust": [ + "function_item": .function, + "struct_item": .struct, + "enum_item": .enum, + "trait_item": .protocol, + "impl_item": .other, + "block": .block, + "if_expression": .if, + "for_expression": .for, + "while_expression": .while, + "match_expression": .switch, + "unsafe_block": .block, + ], + "typescript": [ + "function_declaration": .function, + "method_definition": .method, + "class_declaration": .class, + "interface_declaration": .interface, + "enum_declaration": .enum, + "function": .function, + "arrow_function": .function, + "statement_block": .block, + "if_statement": .if, + "for_statement": .for, + "while_statement": .while, + "switch_statement": .switch, + "try_statement": .try, + ], ] } @@ -135,8 +138,9 @@ nonisolated enum TreeSitterFoldProvider { let lineStarts = Self.lineStarts(in: result.source) var ranges: [TreeSitterFoldRange] = [] + let langMap = TreeSitterFoldKind.typeMap[result.language] ?? [:] for node in result.nodes { - guard let kind = TreeSitterFoldKind.typeMap[node.nodeType] else { + guard let kind = langMap[node.nodeType] else { continue } let startLine = Self.lineNumber( diff --git a/Pine/TreeSitter/TreeSitterSymbolProvider.swift b/Pine/TreeSitter/TreeSitterSymbolProvider.swift index 478c7019..0c53fece 100644 --- a/Pine/TreeSitter/TreeSitterSymbolProvider.swift +++ b/Pine/TreeSitter/TreeSitterSymbolProvider.swift @@ -38,33 +38,36 @@ nonisolated enum TreeSitterSymbolProvider { /// Node-type → PineSymbolKind. Keys are the union of the 4 grammars' type /// names for declarations. - private static let typeMap: [String: PineSymbolKind] = [ - // Swift - "class_declaration": .class, - "struct_declaration": .struct, - "enum_declaration": .enum, - "protocol_declaration": .protocol, - "function_declaration": .function, - "method_declaration": .function, - - // Python - "class_definition": .class, - "function_definition": .function, - - // Rust - "struct_item": .struct, - "enum_item": .enum, - "trait_item": .protocol, - "function_item": .function, - "function_signature_item": .function, - - // TypeScript / TSX - "class_declaration": .class, - "interface_declaration": .interface, - "enum_declaration": .enum, - "function_declaration": .function, - "method_definition": .function, - "function": .function, + /// Per-language node-type → symbol kind. Separate dictionaries per + /// language to avoid duplicate-key crashes. + private static let typeMap: [String: [String: PineSymbolKind]] = [ + "swift": [ + "class_declaration": .class, + "struct_declaration": .struct, + "enum_declaration": .enum, + "protocol_declaration": .protocol, + "function_declaration": .function, + "method_declaration": .function, + ], + "python": [ + "class_definition": .class, + "function_definition": .function, + ], + "rust": [ + "struct_item": .struct, + "enum_item": .enum, + "trait_item": .protocol, + "function_item": .function, + "function_signature_item": .function, + ], + "typescript": [ + "class_declaration": .class, + "interface_declaration": .interface, + "enum_declaration": .enum, + "function_declaration": .function, + "method_definition": .function, + "function": .function, + ], ] /// Extracts symbols from a parsed tree, preserving declaration order and @@ -90,7 +93,7 @@ nonisolated enum TreeSitterSymbolProvider { var declStack: [(node: TreeSitterNodeInfo, depth: Int)] = [] for node in result.nodes { - guard let kind = typeMap[node.nodeType] else { continue } + guard let kind = (typeMap[result.language] ?? [:])[node.nodeType] else { continue } // Pop declarations whose end is before this node's start. while let last = declStack.last,