//
// MCPService.swift
// oAI
//
// MCP (Model Context Protocol) service for filesystem tool execution
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see .
import Foundation
import os
@Observable
class MCPService {
static let shared = MCPService()
private(set) var allowedFolders: [String] = []
private let settings = SettingsService.shared
private let fm = FileManager.default
private let maxFileSize = 10 * 1024 * 1024 // 10 MB
private let maxTextDisplay = 50 * 1024 // 50 KB before truncation
private let maxDirItems = 1000
private let maxSearchResults = 100
private let skipPatterns: Set = [
".git", "node_modules", ".DS_Store", "__pycache__",
".build", ".swiftpm", "Pods", ".Trash", ".Spotlight-V100"
]
/// Cached gitignore rules per allowed folder
private var gitignoreRules: [String: GitignoreParser] = [:]
private init() {
allowedFolders = settings.mcpAllowedFolders
if settings.mcpRespectGitignore {
loadGitignores()
}
}
// MARK: - Folder Management
func addFolder(_ rawPath: String) -> String? {
let expanded = (rawPath as NSString).expandingTildeInPath
let resolved = (expanded as NSString).standardizingPath
var isDir: ObjCBool = false
guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else {
return "Path is not a directory: \(rawPath)"
}
if allowedFolders.contains(resolved) {
return "Folder already added: \(resolved)"
}
allowedFolders.append(resolved)
settings.mcpAllowedFolders = allowedFolders
if settings.mcpRespectGitignore {
loadGitignoreForFolder(resolved)
}
return nil
}
func removeFolder(at index: Int) -> Bool {
guard index >= 0 && index < allowedFolders.count else { return false }
let removed = allowedFolders.remove(at: index)
settings.mcpAllowedFolders = allowedFolders
gitignoreRules.removeValue(forKey: removed)
return true
}
func removeFolder(path: String) -> Bool {
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
if let index = allowedFolders.firstIndex(of: resolved) {
allowedFolders.remove(at: index)
settings.mcpAllowedFolders = allowedFolders
gitignoreRules.removeValue(forKey: resolved)
return true
}
return false
}
func isPathAllowed(_ path: String) -> Bool {
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
return allowedFolders.contains { resolved.hasPrefix($0) }
}
// MARK: - Permission Helpers
var canWriteFiles: Bool { settings.mcpCanWriteFiles }
var canDeleteFiles: Bool { settings.mcpCanDeleteFiles }
var canCreateDirectories: Bool { settings.mcpCanCreateDirectories }
var canMoveFiles: Bool { settings.mcpCanMoveFiles }
var respectGitignore: Bool { settings.mcpRespectGitignore }
private let anytypeService = AnytypeMCPService.shared
// MARK: - Tool Schema Generation
func getToolSchemas() -> [Tool] {
var tools: [Tool] = [
makeTool(
name: "read_file",
description: "Read the contents of a file. Returns the text content of the file. Maximum file size is 10MB.",
properties: [
"file_path": prop("string", "The absolute path to the file to read")
],
required: ["file_path"]
),
makeTool(
name: "list_directory",
description: "List the contents of a directory. Returns file and directory names. Skips hidden/build directories like .git, node_modules, etc.",
properties: [
"dir_path": prop("string", "The absolute path to the directory to list"),
"recursive": prop("boolean", "Whether to list recursively (default: false)")
],
required: ["dir_path"]
),
makeTool(
name: "search_files",
description: "Search for files by name pattern or content. Use 'pattern' for filename glob matching (e.g. '*.swift'). Use 'content_search' for searching inside file contents.",
properties: [
"pattern": prop("string", "Glob pattern to match filenames (e.g. '*.py', 'README*')"),
"search_path": prop("string", "Directory to search in (defaults to first allowed folder)"),
"content_search": prop("string", "Optional text to search for inside files")
],
required: ["pattern"]
)
]
if canWriteFiles {
tools.append(makeTool(
name: "write_file",
description: "Create or overwrite a file with the given content. Parent directories are created automatically. WARNING: For existing files larger than ~200 lines, use edit_file instead — writing very large files in a single call may exceed output token limits and fail.",
properties: [
"file_path": prop("string", "The absolute path to the file to write"),
"content": prop("string", "The text content to write to the file")
],
required: ["file_path", "content"]
))
tools.append(makeTool(
name: "edit_file",
description: "Find and replace text in a file. The old_text must appear exactly once in the file.",
properties: [
"file_path": prop("string", "The absolute path to the file to edit"),
"old_text": prop("string", "The exact text to find (must be a unique match)"),
"new_text": prop("string", "The replacement text")
],
required: ["file_path", "old_text", "new_text"]
))
}
if canDeleteFiles {
tools.append(makeTool(
name: "delete_file",
description: "Delete a file at the given path.",
properties: [
"file_path": prop("string", "The absolute path to the file to delete")
],
required: ["file_path"]
))
}
if canCreateDirectories {
tools.append(makeTool(
name: "create_directory",
description: "Create a directory (and any intermediate directories) at the given path.",
properties: [
"dir_path": prop("string", "The absolute path to the directory to create")
],
required: ["dir_path"]
))
}
if canMoveFiles {
tools.append(makeTool(
name: "move_file",
description: "Move or rename a file or directory.",
properties: [
"source": prop("string", "The absolute path of the file/directory to move"),
"destination": prop("string", "The absolute destination path")
],
required: ["source", "destination"]
))
tools.append(makeTool(
name: "copy_file",
description: "Copy a file or directory to a new location.",
properties: [
"source": prop("string", "The absolute path of the file/directory to copy"),
"destination": prop("string", "The absolute destination path for the copy")
],
required: ["source", "destination"]
))
}
// Add Anytype tools if enabled and configured
if settings.anytypeMcpEnabled && settings.anytypeMcpConfigured {
tools.append(contentsOf: anytypeService.getToolSchemas())
}
return tools
}
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
Tool(
type: "function",
function: Tool.Function(
name: name,
description: description,
parameters: Tool.Function.Parameters(
type: "object",
properties: properties,
required: required
)
)
)
}
private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
}
// MARK: - Tool Execution
func executeTool(name: String, arguments: String) async -> [String: Any] {
Log.mcp.info("Executing tool: \(name)")
guard let argData = arguments.data(using: .utf8),
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
Log.mcp.error("Invalid arguments JSON for tool \(name)")
return ["error": "Invalid arguments JSON"]
}
switch name {
case "read_file":
guard let filePath = args["file_path"] as? String else {
return ["error": "Missing required parameter: file_path"]
}
return readFile(filePath: filePath)
case "list_directory":
guard let dirPath = args["dir_path"] as? String else {
return ["error": "Missing required parameter: dir_path"]
}
let recursive = args["recursive"] as? Bool ?? false
return listDirectory(dirPath: dirPath, recursive: recursive)
case "search_files":
guard let pattern = args["pattern"] as? String else {
return ["error": "Missing required parameter: pattern"]
}
let searchPath = args["search_path"] as? String
let contentSearch = args["content_search"] as? String
return searchFiles(pattern: pattern, searchPath: searchPath, contentSearch: contentSearch)
case "write_file":
guard canWriteFiles else {
return ["error": "Permission denied: write_file is not enabled. Enable 'Write & Edit Files' in Settings > MCP."]
}
guard let filePath = args["file_path"] as? String,
let content = args["content"] as? String else {
return ["error": "Missing required parameters: file_path, content"]
}
return writeFile(filePath: filePath, content: content)
case "edit_file":
guard canWriteFiles else {
return ["error": "Permission denied: edit_file is not enabled. Enable 'Write & Edit Files' in Settings > MCP."]
}
guard let filePath = args["file_path"] as? String,
let oldText = args["old_text"] as? String,
let newText = args["new_text"] as? String else {
return ["error": "Missing required parameters: file_path, old_text, new_text"]
}
return editFile(filePath: filePath, oldText: oldText, newText: newText)
case "delete_file":
guard canDeleteFiles else {
return ["error": "Permission denied: delete_file is not enabled. Enable 'Delete Files' in Settings > MCP."]
}
guard let filePath = args["file_path"] as? String else {
return ["error": "Missing required parameter: file_path"]
}
return deleteFile(filePath: filePath)
case "create_directory":
guard canCreateDirectories else {
return ["error": "Permission denied: create_directory is not enabled. Enable 'Create Directories' in Settings > MCP."]
}
guard let dirPath = args["dir_path"] as? String else {
return ["error": "Missing required parameter: dir_path"]
}
return createDirectory(dirPath: dirPath)
case "move_file":
guard canMoveFiles else {
return ["error": "Permission denied: move_file is not enabled. Enable 'Move & Copy Files' in Settings > MCP."]
}
guard let source = args["source"] as? String,
let destination = args["destination"] as? String else {
return ["error": "Missing required parameters: source, destination"]
}
return moveFile(source: source, destination: destination)
case "copy_file":
guard canMoveFiles else {
return ["error": "Permission denied: copy_file is not enabled. Enable 'Move & Copy Files' in Settings > MCP."]
}
guard let source = args["source"] as? String,
let destination = args["destination"] as? String else {
return ["error": "Missing required parameters: source, destination"]
}
return copyFile(source: source, destination: destination)
default:
// Route anytype_* tools to AnytypeMCPService
if name.hasPrefix("anytype_") {
return await anytypeService.executeTool(name: name, arguments: arguments)
}
return ["error": "Unknown tool: \(name)"]
}
}
// MARK: - Read Tool Implementations
private func readFile(filePath: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Read denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolved) else {
return ["error": "File not found: \(filePath)"]
}
guard let attrs = try? fm.attributesOfItem(atPath: resolved),
let fileSize = attrs[.size] as? Int else {
return ["error": "Cannot read file attributes: \(filePath)"]
}
if fileSize > maxFileSize {
let sizeMB = String(format: "%.1f", Double(fileSize) / 1_000_000)
return ["error": "File too large (\(sizeMB) MB, max 10 MB)"]
}
guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else {
return ["error": "Cannot read file as UTF-8 text: \(filePath)"]
}
var finalContent = content
if content.utf8.count > maxTextDisplay {
let lines = content.components(separatedBy: "\n")
if lines.count > 600 {
let head = lines.prefix(500).joined(separator: "\n")
let tail = lines.suffix(100).joined(separator: "\n")
let omitted = lines.count - 600
finalContent = head + "\n\n... [\(omitted) lines omitted] ...\n\n" + tail
}
}
return ["content": finalContent, "path": resolved, "size": fileSize]
}
private func listDirectory(dirPath: String, recursive: Bool) -> [String: Any] {
let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
return ["error": "Access denied: path is outside allowed folders"]
}
var isDir: ObjCBool = false
guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else {
return ["error": "Directory not found: \(dirPath)"]
}
var items: [String] = []
if recursive {
guard let enumerator = fm.enumerator(atPath: resolved) else {
return ["error": "Cannot enumerate directory"]
}
while let item = enumerator.nextObject() as? String {
let components = item.components(separatedBy: "/")
if components.contains(where: { skipPatterns.contains($0) }) {
enumerator.skipDescendants()
continue
}
let fullPath = (resolved as NSString).appendingPathComponent(item)
if respectGitignore && isGitignored(fullPath) {
// Skip gitignored directories entirely
var itemIsDir: ObjCBool = false
if fm.fileExists(atPath: fullPath, isDirectory: &itemIsDir), itemIsDir.boolValue {
enumerator.skipDescendants()
}
continue
}
items.append(item)
if items.count >= maxDirItems { break }
}
} else {
guard let contents = try? fm.contentsOfDirectory(atPath: resolved) else {
return ["error": "Cannot list directory"]
}
items = contents.filter { !skipPatterns.contains($0) }
.filter { entry in
if respectGitignore {
let fullPath = (resolved as NSString).appendingPathComponent(entry)
return !isGitignored(fullPath)
}
return true
}
.prefix(maxDirItems).map { entry in
var entryIsDir: ObjCBool = false
let fullPath = (resolved as NSString).appendingPathComponent(entry)
fm.fileExists(atPath: fullPath, isDirectory: &entryIsDir)
return entryIsDir.boolValue ? "\(entry)/" : entry
}
}
let truncated = items.count >= maxDirItems
return ["items": items, "count": items.count, "truncated": truncated, "path": resolved]
}
private func searchFiles(pattern: String, searchPath: String?, contentSearch: String?) -> [String: Any] {
let basePath: String
if let sp = searchPath {
basePath = ((sp as NSString).expandingTildeInPath as NSString).standardizingPath
} else if let first = allowedFolders.first {
basePath = first
} else {
return ["error": "No search path specified and no allowed folders configured"]
}
guard isPathAllowed(basePath) else {
return ["error": "Access denied: search path is outside allowed folders"]
}
guard let enumerator = fm.enumerator(atPath: basePath) else {
return ["error": "Cannot enumerate directory: \(basePath)"]
}
var results: [String] = []
while let item = enumerator.nextObject() as? String {
let components = item.components(separatedBy: "/")
if components.contains(where: { skipPatterns.contains($0) }) {
enumerator.skipDescendants()
continue
}
let fullPath = (basePath as NSString).appendingPathComponent(item)
if respectGitignore && isGitignored(fullPath) {
var itemIsDir: ObjCBool = false
if fm.fileExists(atPath: fullPath, isDirectory: &itemIsDir), itemIsDir.boolValue {
enumerator.skipDescendants()
}
continue
}
let filename = (item as NSString).lastPathComponent
// Filename pattern match
if fnmatch(pattern, filename, 0) != 0 {
continue
}
// Content search if requested
if let searchText = contentSearch, !searchText.isEmpty {
guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else {
continue
}
if !content.localizedCaseInsensitiveContains(searchText) {
continue
}
}
results.append(item)
if results.count >= maxSearchResults { break }
}
let truncated = results.count >= maxSearchResults
return ["matches": results, "count": results.count, "truncated": truncated, "base_path": basePath]
}
// MARK: - Write Tool Implementations
private func writeFile(filePath: String, content: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Write denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
// Create parent directories if needed
let parentDir = (resolved as NSString).deletingLastPathComponent
if !fm.fileExists(atPath: parentDir) {
do {
try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create parent directories: \(error.localizedDescription)"]
}
}
do {
try content.write(toFile: resolved, atomically: true, encoding: .utf8)
} catch {
Log.mcp.error("Failed to write file \(resolved): \(error.localizedDescription)")
return ["error": "Cannot write file: \(error.localizedDescription)"]
}
Log.mcp.info("Wrote \(content.utf8.count) bytes to \(resolved)")
return ["success": true, "path": resolved, "bytes_written": content.utf8.count]
}
private func editFile(filePath: String, oldText: String, newText: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Edit denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolved) else {
return ["error": "File not found: \(filePath)"]
}
guard let content = try? String(contentsOfFile: resolved, encoding: .utf8) else {
return ["error": "Cannot read file as UTF-8 text: \(filePath)"]
}
// Count occurrences
let occurrences = content.components(separatedBy: oldText).count - 1
if occurrences == 0 {
return ["error": "old_text not found in file"]
}
if occurrences > 1 {
return ["error": "old_text appears \(occurrences) times in the file — it must be unique (exactly 1 match). Provide more surrounding context to make it unique."]
}
let newContent = content.replacingOccurrences(of: oldText, with: newText)
do {
try newContent.write(toFile: resolved, atomically: true, encoding: .utf8)
} catch {
return ["error": "Cannot write file: \(error.localizedDescription)"]
}
return ["success": true, "path": resolved]
}
private func deleteFile(filePath: String) -> [String: Any] {
let resolved = ((filePath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
Log.mcp.warning("Delete denied: path outside allowed folders: \(resolved)")
return ["error": "Access denied: path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolved) else {
return ["error": "File not found: \(filePath)"]
}
do {
try fm.removeItem(atPath: resolved)
} catch {
Log.mcp.error("Failed to delete \(resolved): \(error.localizedDescription)")
return ["error": "Cannot delete file: \(error.localizedDescription)"]
}
Log.mcp.info("Deleted \(resolved)")
return ["success": true, "path": resolved]
}
private func createDirectory(dirPath: String) -> [String: Any] {
let resolved = ((dirPath as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolved) else {
return ["error": "Access denied: path is outside allowed folders"]
}
do {
try fm.createDirectory(atPath: resolved, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create directory: \(error.localizedDescription)"]
}
return ["success": true, "path": resolved]
}
private func moveFile(source: String, destination: String) -> [String: Any] {
let resolvedSrc = ((source as NSString).expandingTildeInPath as NSString).standardizingPath
let resolvedDst = ((destination as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolvedSrc) else {
return ["error": "Access denied: source path is outside allowed folders"]
}
guard isPathAllowed(resolvedDst) else {
return ["error": "Access denied: destination path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolvedSrc) else {
return ["error": "Source not found: \(source)"]
}
// Create parent directory of destination if needed
let parentDir = (resolvedDst as NSString).deletingLastPathComponent
if !fm.fileExists(atPath: parentDir) {
do {
try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create destination directory: \(error.localizedDescription)"]
}
}
do {
try fm.moveItem(atPath: resolvedSrc, toPath: resolvedDst)
} catch {
return ["error": "Cannot move file: \(error.localizedDescription)"]
}
return ["success": true, "source": resolvedSrc, "destination": resolvedDst]
}
private func copyFile(source: String, destination: String) -> [String: Any] {
let resolvedSrc = ((source as NSString).expandingTildeInPath as NSString).standardizingPath
let resolvedDst = ((destination as NSString).expandingTildeInPath as NSString).standardizingPath
guard isPathAllowed(resolvedSrc) else {
return ["error": "Access denied: source path is outside allowed folders"]
}
guard isPathAllowed(resolvedDst) else {
return ["error": "Access denied: destination path is outside allowed folders"]
}
guard fm.fileExists(atPath: resolvedSrc) else {
return ["error": "Source not found: \(source)"]
}
// Create parent directory of destination if needed
let parentDir = (resolvedDst as NSString).deletingLastPathComponent
if !fm.fileExists(atPath: parentDir) {
do {
try fm.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
} catch {
return ["error": "Cannot create destination directory: \(error.localizedDescription)"]
}
}
do {
try fm.copyItem(atPath: resolvedSrc, toPath: resolvedDst)
} catch {
return ["error": "Cannot copy file: \(error.localizedDescription)"]
}
return ["success": true, "source": resolvedSrc, "destination": resolvedDst]
}
// MARK: - Gitignore Support
/// Reload gitignore rules for all allowed folders
func reloadGitignores() {
gitignoreRules.removeAll()
if settings.mcpRespectGitignore {
loadGitignores()
}
}
private func loadGitignores() {
for folder in allowedFolders {
loadGitignoreForFolder(folder)
}
}
private func loadGitignoreForFolder(_ folder: String) {
var parser = GitignoreParser(rootPath: folder)
parser.loadRules(fileManager: fm)
gitignoreRules[folder] = parser
}
/// Check if an absolute path is gitignored by any loaded gitignore rules
func isGitignored(_ absolutePath: String) -> Bool {
guard settings.mcpRespectGitignore else { return false }
for (folder, parser) in gitignoreRules {
if absolutePath.hasPrefix(folder) {
let relativePath = String(absolutePath.dropFirst(folder.count).drop(while: { $0 == "/" }))
if !relativePath.isEmpty && parser.isIgnored(relativePath) {
return true
}
}
}
return false
}
}
// MARK: - GitignoreParser
/// Parses .gitignore files and checks paths against the patterns.
/// Supports: wildcards (*), double wildcards (**), directory patterns (/), negation (!), comments (#).
struct GitignoreParser {
let rootPath: String
private var rules: [GitignoreRule] = []
struct GitignoreRule {
let pattern: String
let isNegation: Bool
let isDirectoryOnly: Bool
/// Regex compiled from the gitignore glob pattern
let regex: NSRegularExpression?
}
init(rootPath: String) {
self.rootPath = rootPath
}
/// Load .gitignore from the root path (non-recursive — only the root .gitignore)
mutating func loadRules(fileManager fm: FileManager) {
let gitignorePath = (rootPath as NSString).appendingPathComponent(".gitignore")
guard let content = try? String(contentsOfFile: gitignorePath, encoding: .utf8) else {
return
}
parseContent(content)
}
mutating func parseContent(_ content: String) {
let lines = content.components(separatedBy: .newlines)
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
// Skip empty lines and comments
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
var pattern = trimmed
let isNegation = pattern.hasPrefix("!")
if isNegation {
pattern = String(pattern.dropFirst())
}
// Remove trailing spaces (unless escaped)
while pattern.hasSuffix(" ") && !pattern.hasSuffix("\\ ") {
pattern = String(pattern.dropLast())
}
let isDirectoryOnly = pattern.hasSuffix("/")
if isDirectoryOnly {
pattern = String(pattern.dropLast())
}
// Remove leading slash (anchors to root, but we match relative paths)
if pattern.hasPrefix("/") {
pattern = String(pattern.dropFirst())
}
guard !pattern.isEmpty else { continue }
let regex = gitignorePatternToRegex(pattern)
rules.append(GitignoreRule(
pattern: pattern,
isNegation: isNegation,
isDirectoryOnly: isDirectoryOnly,
regex: regex
))
}
}
/// Check if a relative path (from rootPath) is ignored
func isIgnored(_ relativePath: String) -> Bool {
var ignored = false
for rule in rules {
let matches: Bool
if let regex = rule.regex {
let range = NSRange(relativePath.startIndex..., in: relativePath)
matches = regex.firstMatch(in: relativePath, range: range) != nil
} else {
// Fallback: simple contains check for the pattern basename
let basename = (relativePath as NSString).lastPathComponent
matches = basename == rule.pattern
}
if matches {
if rule.isNegation {
ignored = false
} else {
ignored = true
}
}
}
return ignored
}
/// Convert a gitignore glob pattern to a regex
private func gitignorePatternToRegex(_ pattern: String) -> NSRegularExpression? {
var regex = ""
let chars = Array(pattern)
var i = 0
// If the pattern contains no slash, it matches against the filename only
let matchesPath = pattern.contains("/")
if !matchesPath {
// Match against any path component — the pattern can appear as the last component
regex += "(?:^|/)"
} else {
regex += "^"
}
while i < chars.count {
let c = chars[i]
switch c {
case "*":
if i + 1 < chars.count && chars[i + 1] == "*" {
// **
if i + 2 < chars.count && chars[i + 2] == "/" {
// **/ — matches zero or more directories
regex += "(?:.+/)?"
i += 3
continue
} else {
// ** at end — matches everything
regex += ".*"
i += 2
continue
}
} else {
// Single * — matches anything except /
regex += "[^/]*"
}
case "?":
regex += "[^/]"
case ".":
regex += "\\."
case "[":
// Character class — pass through
regex += "["
case "]":
regex += "]"
case "\\":
// Escape next character
if i + 1 < chars.count {
i += 1
regex += NSRegularExpression.escapedPattern(for: String(chars[i]))
}
default:
regex += NSRegularExpression.escapedPattern(for: String(c))
}
i += 1
}
// Allow matching as a prefix (directory) or exact match
regex += "(?:/.*)?$"
return try? NSRegularExpression(pattern: regex, options: [])
}
}