diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..0d7a7bfa --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Nextcloud GmbH +# SPDX-License-Identifier: GPL-3.0-or-later + +version: 2 +updates: + # Swift Package Manager dependencies (Alamofire, NextcloudKit, swift-markdown, …) + - package-ecosystem: "swift" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + commit-message: + prefix: "fix(deps)" + + # Keep the GitHub Actions used by our workflows up to date + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + commit-message: + prefix: "ci" diff --git a/.github/workflows/.swiftlint.yml b/.github/workflows/swiftlint.yml similarity index 100% rename from .github/workflows/.swiftlint.yml rename to .github/workflows/swiftlint.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..9ce0d43c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: Nextcloud GmbH +# SPDX-License-Identifier: GPL-3.0-or-later + +name: Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: macos-15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select latest installed Xcode + run: | + # Pick the newest Xcode present on the runner instead of hard-coding a + # path, so the job keeps working as runner images change. + LATEST=$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort -V | tail -1) + LATEST=${LATEST:-/Applications/Xcode.app} + echo "Selecting $LATEST" + sudo xcode-select --switch "$LATEST" + + - name: Show versions + run: | + xcodebuild -version + swift --version + + - name: Resolve Swift package dependencies + run: | + xcodebuild -resolvePackageDependencies \ + -project iOCNotes.xcodeproj \ + -scheme iOCNotes + + - name: Pick an iOS simulator + id: sim + run: | + # Choose the newest available iPhone simulator so we don't hard-code a + # device name that may change between Xcode releases. + cat > /tmp/pick_sim.py <<'PY' + import json, sys + devices = json.load(sys.stdin)["devices"] + cands = [d for rt, ds in devices.items() if "iOS" in rt + for d in ds if d.get("isAvailable") and "iPhone" in d["name"]] + print(cands[-1]["udid"] if cands else "") + PY + UDID=$(xcrun simctl list devices available --json | python3 /tmp/pick_sim.py) + if [ -z "$UDID" ]; then + echo "No iOS simulator available" >&2 + xcrun simctl list devices available + exit 1 + fi + echo "Using simulator $UDID" + echo "udid=$UDID" >> "$GITHUB_OUTPUT" + + - name: Run unit tests + run: | + xcodebuild test \ + -project iOCNotes.xcodeproj \ + -scheme iOCNotes \ + -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ + -resultBundlePath TestResults.xcresult \ + -skipPackagePluginValidation \ + -parallel-testing-enabled NO \ + CODE_SIGNING_ALLOWED=NO + + - name: Show failure details + if: failure() + run: | + # Surface the actual test/crash reason from the result bundle, since a + # host-app launch failure prints nothing useful to the build log. + echo "===== test results summary =====" + xcrun xcresulttool get test-results summary \ + --path TestResults.xcresult || true + echo "===== crash backtraces =====" + xcrun xcresulttool export diagnostics \ + --path TestResults.xcresult \ + --output-path _diagnostics 2>/dev/null || true + find _diagnostics TestResults.xcresult \ + \( -name "*.crash" -o -name "*.ips" \) 2>/dev/null \ + | while read -r f; do echo "--- $f ---"; sed -n '1,150p' "$f" || true; done + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: TestResults + path: TestResults.xcresult + if-no-files-found: ignore diff --git a/Editor/Extensions.swift b/Editor/Extensions.swift index 7a9decd0..94056b20 100644 --- a/Editor/Extensions.swift +++ b/Editor/Extensions.swift @@ -47,10 +47,18 @@ extension String { /// /// - returns: The equivalent NSRange. func nsRange(from range: Range) -> NSRange { - let from = range.lowerBound.samePosition(in: utf16) - let to = range.upperBound.samePosition(in: utf16) - return NSRange(location: utf16.distance(from: utf16.startIndex, to: from!), - length: utf16.distance(from: from!, to: to!)) + // Guard against indices that do not belong to this string (for example a + // range computed against a longer, stale version of the text). Feeding + // such indices to `utf16.distance` traps with "String index is out of + // bounds", so fall back to an empty range instead of crashing. + guard range.lowerBound >= startIndex, + range.upperBound <= endIndex, + let from = range.lowerBound.samePosition(in: utf16), + let to = range.upperBound.samePosition(in: utf16) else { + return NSRange(location: NSNotFound, length: 0) + } + return NSRange(location: utf16.distance(from: utf16.startIndex, to: from), + length: utf16.distance(from: from, to: to)) } /// Converts a String to a NSRegularExpression. diff --git a/Editor/MarkdownTextStorage.swift b/Editor/MarkdownTextStorage.swift index 8f032f1d..b54e5251 100644 --- a/Editor/MarkdownTextStorage.swift +++ b/Editor/MarkdownTextStorage.swift @@ -60,10 +60,18 @@ public class MarkdownTextStorage: NSTextStorage { override public func processEditing() { let backingString = backingStore.string - if let nsRange = backingString.range(from: NSMakeRange(NSMaxRange(editedRange), 0)) { + let fullRange = NSRange(location: 0, length: (backingString as NSString).length) + // The edited range can point just past the end of the string (or be stale + // relative to the backing store), so clamp before converting to avoid + // out-of-bounds string-index math. + let location = min(NSMaxRange(editedRange), fullRange.length) + if let nsRange = backingString.range(from: NSMakeRange(location, 0)) { let indexRange = backingString.lineRange(for: nsRange) - let extendedRange: NSRange = NSUnionRange(editedRange, backingString.nsRange(from: indexRange)) - applyMarkdownFormatting(extendedRange) + let lineNSRange = backingString.nsRange(from: indexRange) + if lineNSRange.location != NSNotFound { + let extendedRange = NSIntersectionRange(NSUnionRange(editedRange, lineNSRange), fullRange) + applyMarkdownFormatting(extendedRange) + } } super.processEditing() } diff --git a/IOCNotesUnitTests/MarkdownTextStorageTests.swift b/IOCNotesUnitTests/MarkdownTextStorageTests.swift index 05dcfb7d..f6b70b7e 100644 --- a/IOCNotesUnitTests/MarkdownTextStorageTests.swift +++ b/IOCNotesUnitTests/MarkdownTextStorageTests.swift @@ -16,8 +16,10 @@ struct MarkdownTextStorageTests { let attributedString = NSAttributedString(string: text) textStorage.setAttributedString(attributedString) - // Manually trigger formatting since we're not in a text view - let range = NSRange(location: 0, length: text.count) + // Manually trigger formatting since we're not in a text view. + // Use the UTF-16 length (not the grapheme count) since that is what + // NSTextStorage/NSAttributedString ranges are measured in. + let range = NSRange(location: 0, length: (text as NSString).length) textStorage.edited([.editedCharacters, .editedAttributes], range: range, changeInLength: 0) textStorage.processEditing() diff --git a/iOCNotes.xcodeproj/xcshareddata/xcschemes/iOCNotes.xcscheme b/iOCNotes.xcodeproj/xcshareddata/xcschemes/iOCNotes.xcscheme index ec0e6e8f..4a29d5e3 100644 --- a/iOCNotes.xcodeproj/xcshareddata/xcschemes/iOCNotes.xcscheme +++ b/iOCNotes.xcodeproj/xcshareddata/xcschemes/iOCNotes.xcscheme @@ -29,16 +29,6 @@ shouldUseLaunchSchemeArgsEnv = "YES" codeCoverageEnabled = "YES"> - - - -