Snippet

Open and save UTF-8 plain-text files safely

A complete Swift file service for .txt and .md documents with strict UTF-8 decoding, atomic saves, and a 10 MB warning.

swiftOct 6, 2026MIT

AI-generated, human-reviewed: the first draft was generated by an AI agent; I fact-checked, tested, and edited it. AI use policy

What it does#

Opens only .txt and .md files, refuses invalid UTF-8 instead of silently corrupting it, warns for files above 10 MB, and writes saves atomically. The file service is intentionally boring. Boring is excellent when documents are involved.

You need#

  • macOS 15 or later
  • Xcode 27 or Swift 6.4
  • Foundation only
  • A caller that shows the large-file warning and asks the user before reopening with allowLargeFile: true

Where it goes#

Save it as UTF8DocumentFileService.swift in a Swift package, app target, or shared core module.

UTF8DocumentFileService.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Sarang Datye
import Foundation

let largeFileWarningThresholdBytes = 10 * 1024 * 1024

enum PlainTextDocumentType: String, Codable, Equatable, Sendable {
    case txt
    case md
    case unknown

    init(url: URL?) {
        guard let ext = url?.pathExtension.lowercased(), !ext.isEmpty else {
            self = .unknown
            return
        }
        self = PlainTextDocumentType(rawValue: ext) ?? .unknown
    }

    var displayName: String {
        switch self {
        case .txt: return ".txt"
        case .md: return ".md"
        case .unknown: return "Plain text"
        }
    }
}

enum PlainTextFileError: Error, Equatable, LocalizedError, Sendable {
    case unsupportedExtension(String)
    case invalidUTF8
    case invalidFileName(String)
    case readFailed(String)
    case writeFailed(String)

    var errorDescription: String? {
        switch self {
        case .unsupportedExtension(let ext):
            "Only UTF-8 .txt and .md files are supported. Unsupported extension: \(ext)."
        case .invalidUTF8:
            "This file is not valid UTF-8, so it was not opened to avoid silent corruption."
        case .invalidFileName(let name):
            "Save As names must be single file names, not paths. Invalid name: \(name)."
        case .readFailed(let message):
            "Could not read file: \(message)"
        case .writeFailed(let message):
            "Could not save file: \(message)"
        }
    }
}

struct PlainTextDocument: Equatable, Sendable {
    var title: String
    var text: String
    var fileURL: URL?
    var documentType: PlainTextDocumentType
    var isDirty: Bool
}

struct OpenPlainTextDocumentResult: Equatable, Sendable {
    var document: PlainTextDocument
    var shouldWarnLargeFile: Bool
}

struct UTF8DocumentFileService {
    var fileManager: FileManager

    init(fileManager: FileManager = .default) {
        self.fileManager = fileManager
    }

    func open(url: URL, allowLargeFile: Bool = false) throws -> OpenPlainTextDocumentResult {
        let type = PlainTextDocumentType(url: url)
        if type == .unknown {
            throw PlainTextFileError.unsupportedExtension(url.pathExtension)
        }

        let attrs = try? fileManager.attributesOfItem(atPath: url.path)
        let size = (attrs?[.size] as? NSNumber)?.int64Value ?? 0
        let shouldWarn = size > Int64(largeFileWarningThresholdBytes)
        if shouldWarn && !allowLargeFile {
            return OpenPlainTextDocumentResult(
                document: PlainTextDocument(
                    title: url.lastPathComponent,
                    text: "",
                    fileURL: url,
                    documentType: type,
                    isDirty: false
                ),
                shouldWarnLargeFile: true
            )
        }

        let data: Data
        do {
            data = try Data(contentsOf: url)
        } catch {
            throw PlainTextFileError.readFailed(error.localizedDescription)
        }

        guard let text = String(data: data, encoding: .utf8) else {
            throw PlainTextFileError.invalidUTF8
        }

        return OpenPlainTextDocumentResult(
            document: PlainTextDocument(
                title: url.lastPathComponent,
                text: text,
                fileURL: url,
                documentType: type,
                isDirty: false
            ),
            shouldWarnLargeFile: shouldWarn
        )
    }

    func save(document: PlainTextDocument, to url: URL) throws -> PlainTextDocument {
        let type = PlainTextDocumentType(url: url)
        if type == .unknown {
            throw PlainTextFileError.unsupportedExtension(url.pathExtension)
        }

        guard let data = document.text.data(using: .utf8) else {
            throw PlainTextFileError.invalidUTF8
        }

        do {
            try data.write(to: url, options: [.atomic])
            return PlainTextDocument(
                title: url.lastPathComponent,
                text: document.text,
                fileURL: url,
                documentType: type,
                isDirty: false
            )
        } catch {
            throw PlainTextFileError.writeFailed(error.localizedDescription)
        }
    }

    func defaultSaveAsURL(in folder: URL, proposedName: String = "Untitled") throws -> URL {
        let name = proposedName.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !name.isEmpty,
              !name.contains("/"),
              !name.contains("\\"),
              URL(fileURLWithPath: name).lastPathComponent == name else {
            throw PlainTextFileError.invalidFileName(proposedName)
        }

        let baseName = (name as NSString).deletingPathExtension
        let safeName = baseName.isEmpty ? "Untitled" : baseName
        return folder.appendingPathComponent(safeName).appendingPathExtension("txt")
    }
}

Check that it works#

Create a temporary .txt file, open it, save it to a second file, try opening a binary file, and call defaultSaveAsURL with ../escape. The binary file should fail with invalidUTF8, the extensionless open should fail with unsupportedExtension, and the traversal name should fail with invalidFileName.