diff --git a/scripts/ci/lint-rules.test.ts b/scripts/ci/lint-rules.test.ts index 260077ee74..4e45039639 100644 --- a/scripts/ci/lint-rules.test.ts +++ b/scripts/ci/lint-rules.test.ts @@ -11,6 +11,7 @@ import { BARE_REQUIRE_RATCHET, changedFiles, checkSource, + checkSwiftSource, namesToolCommand, namesTranscriptFormat, pluginsDisabledFor, @@ -32,6 +33,82 @@ function rules(source: string, file = "src/example.ts"): string[] { return checkSource(file, source).map((f) => f.rule); } +describe("swift-no-entry-macro", () => { + const swiftRules = (source: string) => checkSwiftSource("Sources/Example.swift", source).map((f) => f.rule); + + test("flags @Entry, which needs Xcode's SwiftUIMacros plugin", () => { + expect(swiftRules("extension EnvironmentValues {\n @Entry public var flag = false\n}\n")).toEqual([ + "swift-no-entry-macro", + ]); + }); + + test("ignores inline comments and ordinary, escaped, raw and multiline string literals", () => { + const source = [ + "let flag = false // @Entry", + 'let text = "@Entry"', + 'let escaped = "quote: \\" @Entry"', + 'let raw = #"@Entry "quoted""#', + 'let multiline = """', + "@Entry", + '"""', + 'let rawMultiline = ##"""', + '@Entry """ still raw', + '"""##', + 'let multipleHashes = ##"@Entry"##', + String.raw`let interpolated = "\("@Entry")"`, + String.raw`let rawInterpolated = #"\#("@Entry")"#`, + ].join("\n"); + expect(swiftRules(source)).toEqual([]); + }); + + test("ignores nested block comments while preserving the position of a real attribute", () => { + const source = [ + "/* @Entry", + " /* nested @Entry */ still a comment", + "*/ extension EnvironmentValues {", + "/* @Entry */ @Entry var flag = false", + "}", + ].join("\n"); + expect(checkSwiftSource("Sources/Example.swift", source)).toMatchObject([ + { rule: "swift-no-entry-macro", line: 4, column: 14, text: "/* @Entry */ @Entry var flag = false" }, + ]); + }); + + test("still flags attributes after strings and honours an explicit suppression", () => { + expect(swiftRules('let text = "@Entry"; @Entry var flag = false')).toEqual(["swift-no-entry-macro"]); + expect(swiftRules('let raw = #"@Entry"#; @Entry var flag = false')).toEqual(["swift-no-entry-macro"]); + expect(swiftRules("// lint-rules-ignore: fixture\n@Entry var flag = false")).toEqual([]); + }); + + test("ignores hash-delimited regex contents, including quotes and escaped delimiters", () => { + for (const pattern of ["#/@Entry/#", '#/"/#', '##/@Entry /# " /##', String.raw`#/\/# @Entry/#`]) { + expect(swiftRules(`let pattern = ${pattern}`)).toEqual([]); + } + + expect(swiftRules('let pattern = #/\n@Entry " // /*\n/#')).toEqual([]); + }); + + test("a quote inside a regex cannot hide a later real attribute or move its diagnostic", () => { + for (const pattern of ['#/"/#', '##/" /# @Entry /##', String.raw`#/\/# " @Entry/#`]) { + const source = `let pattern = ${pattern}\n@Entry var flag = false`; + expect(checkSwiftSource("Sources/Example.swift", source)).toMatchObject([ + { rule: "swift-no-entry-macro", line: 2, column: 1, text: "@Entry var flag = false" }, + ]); + } + }); + + test("leaves a hand-written EnvironmentKey and a mention in a comment alone", () => { + const source = [ + "private struct FlagKey: EnvironmentKey { static let defaultValue = false }", + "// was @Entry before #564", + "extension EnvironmentValues {", + " var flag: Bool { get { self[FlagKey.self] } set { self[FlagKey.self] = newValue } }", + "}", + ].join("\n"); + expect(swiftRules(source)).toEqual([]); + }); +}); + describe("no-hardcoded-tmp", () => { test("flags a /tmp literal in the binding contexts GritQL enumerated", () => { expect(rules('const p = "/tmp/x";')).toContain("no-hardcoded-tmp"); diff --git a/scripts/ci/lint-rules.ts b/scripts/ci/lint-rules.ts index 44299a4be5..ed684bb275 100644 --- a/scripts/ci/lint-rules.ts +++ b/scripts/ci/lint-rules.ts @@ -766,9 +766,20 @@ const SWIFT_CLICK_COUNT_RULE = { }; const SWIFT_CURRENT_EVENT_CLICK_COUNT = /currentEvent\s*[?!]?\s*\.clickCount/; +const SWIFT_ENTRY_MACRO_RULE = { + name: "swift-no-entry-macro", + severity: "error" as const, + message: + "`@Entry` is a SwiftUI macro: its plugin (SwiftUIMacros) ships only with Xcode, so the app stops building on " + + "a Mac with only the Command Line Tools (#564). Write a `private struct Key: EnvironmentKey` and a " + + "computed `EnvironmentValues` property with `get`/`set`, as `Style/MotionEnvironment.swift` does.", +}; +const SWIFT_ENTRY_MACRO = /@Entry\b/; + const SWIFT_LINE_RULES = [ { rule: SWIFT_WAIT_RULE, pattern: SWIFT_BARE_WAIT }, { rule: SWIFT_CLICK_COUNT_RULE, pattern: SWIFT_CURRENT_EVENT_CLICK_COUNT }, + { rule: SWIFT_ENTRY_MACRO_RULE, pattern: SWIFT_ENTRY_MACRO }, ]; /** @@ -1080,9 +1091,101 @@ function recordBusyWaitFindings(root: SgNode, record: RecordFinding): void { recordSleepSyncFindings(root.findAll(SLEEP_SYNC_IN_LOOP_MATCHER), record); } +interface SwiftLintString { + end: string; + hashes: string; + interpolationDepth: number; +} + +/** Blank Swift comments, strings and hash-delimited regexes without moving code's line or column offsets. */ +function maskSwiftNonCode(source: string): string { + const masked = source.split(""); + let index = 0; + let blockDepth = 0; + let regexEnd = ""; + const strings: SwiftLintString[] = []; + + const blank = (end: number) => { + while (index < end) { + if (source[index] !== "\n" && source[index] !== "\r") { + masked[index] = " "; + } + + index++; + } + }; + + while (index < source.length) { + const string = strings.at(-1); + + if (blockDepth > 0) { + if (source.startsWith("/*", index)) { + blockDepth++; + blank(index + 2); + } else if (source.startsWith("*/", index)) { + blockDepth--; + blank(index + 2); + } else { + blank(index + 1); + } + } else if (regexEnd) { + if (source[index] === "\\") { + blank(Math.min(index + 2, source.length)); + } else if (source.startsWith(regexEnd, index)) { + blank(index + regexEnd.length); + regexEnd = ""; + } else { + blank(index + 1); + } + } else if (string?.interpolationDepth === 0) { + if (source.startsWith(string.end, index)) { + blank(index + string.end.length); + strings.pop(); + } else if (source.startsWith(`\\${string.hashes}(`, index)) { + blank(index + string.hashes.length + 2); + string.interpolationDepth = 1; + } else if (source.startsWith(`\\${string.hashes}`, index)) { + blank(Math.min(index + string.hashes.length + 2, source.length)); + } else { + blank(index + 1); + } + } else if (source.startsWith("//", index)) { + const newline = source.indexOf("\n", index); + blank(newline === -1 ? source.length : newline); + } else if (source.startsWith("/*", index)) { + blockDepth = 1; + blank(index + 2); + } else if (source[index] === '"' || source[index] === "#") { + const regexOpening = /^(#+)\//.exec(source.slice(index)); + const opening = /^(#*)("{3}|")/.exec(source.slice(index)); + + if (regexOpening) { + regexEnd = `/${regexOpening[1]}`; + blank(index + regexOpening[0].length); + } else if (opening) { + strings.push({ end: opening[2] + opening[1], hashes: opening[1], interpolationDepth: 0 }); + blank(index + opening[0].length); + } else { + index++; + } + } else if (string && source[index] === "(") { + string.interpolationDepth++; + index++; + } else if (string && source[index] === ")") { + string.interpolationDepth--; + blank(index + 1); + } else { + index++; + } + } + + return masked.join(""); +} + /** Swift has no grammar in the ast-grep napi package, so its rules are line regexes. */ export function checkSwiftSource(file: string, source: string): Finding[] { const lines = source.split("\n"); + const codeLines = maskSwiftNonCode(source).split("\n"); const findings: Finding[] = []; for (const [index, text] of lines.entries()) { @@ -1091,7 +1194,7 @@ export function checkSwiftSource(file: string, source: string): Finding[] { } for (const { rule, pattern } of SWIFT_LINE_RULES) { - const column = text.search(pattern); + const column = (rule === SWIFT_ENTRY_MACRO_RULE ? codeLines[index] : text).search(pattern); if (column === -1) { continue; diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeGlass.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeGlass.swift index d42fcd2f18..3188d6bb7f 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeGlass.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeGlass.swift @@ -229,10 +229,15 @@ public enum NativePaneLevel: Sendable { case bar } +private struct NativePanesKey: EnvironmentKey { static let defaultValue = false } + extension EnvironmentValues { /// On in a host that draws its panes in the native settings look over a `NativeSettingsBackdrop` (the hub's /// Native style). GenesisKit panes then draw `NativePaneFill` instead of their own palette. - @Entry public var nativePanes = false + public var nativePanes: Bool { + get { self[NativePanesKey.self] } + set { self[NativePanesKey.self] = newValue } + } } /// The fill of one pane in the native settings look, for the theme in the environment. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift index 87fdb86fa1..52cf0d8de2 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift @@ -115,14 +115,27 @@ struct NativeSettingsSearchKey: PreferenceKey { } } +private struct NativeSettingsPageIDKey: EnvironmentKey { static let defaultValue: String? = nil } +private struct NativeSettingsSearchCardKey: EnvironmentKey { static let defaultValue: String? = nil } +private struct NativeSettingsIndexingKey: EnvironmentKey { static let defaultValue = false } + extension EnvironmentValues { /// The settings page a card or row is drawn on; nil outside the settings window. - @Entry var nativeSettingsPageID: String? = nil + var nativeSettingsPageID: String? { + get { self[NativeSettingsPageIDKey.self] } + set { self[NativeSettingsPageIDKey.self] = newValue } + } /// The title of the titled card a row is drawn in, so the row's search key and its jump target name the card. - @Entry var nativeSettingsSearchCard: String? = nil + var nativeSettingsSearchCard: String? { + get { self[NativeSettingsSearchCardKey.self] } + set { self[NativeSettingsSearchCardKey.self] = newValue } + } /// True in the hidden copy of the pages the search reads: nothing there flashes, and a page's own code must not /// speak (`GenAnnounce`) or ask for anything, since nobody sees it. - @Entry public var nativeSettingsIndexing = false + public var nativeSettingsIndexing: Bool { + get { self[NativeSettingsIndexingKey.self] } + set { self[NativeSettingsIndexingKey.self] = newValue } + } } /// A card or row the search can jump to: when a jump names it, it flashes, and its marker is what the page scrolls to. diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Window/WindowTitlebar.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Window/WindowTitlebar.swift index ee956e5a85..0213e08b19 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Window/WindowTitlebar.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Window/WindowTitlebar.swift @@ -373,8 +373,13 @@ public struct TitlebarMetrics: Equatable { public var leadingReserve: CGFloat = 0 } +private struct TitlebarMetricsKey: EnvironmentKey { static let defaultValue: TitlebarMetrics = TitlebarMetrics() } + extension EnvironmentValues { - @Entry public var titlebarMetrics = TitlebarMetrics() + public var titlebarMetrics: TitlebarMetrics { + get { self[TitlebarMetricsKey.self] } + set { self[TitlebarMetricsKey.self] = newValue } + } } /// The empty part of the strip. It lies behind the window's SwiftUI content, so a control in the diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPanels.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPanels.swift index e9b064e8a1..cfa161679f 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPanels.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPanels.swift @@ -510,9 +510,14 @@ struct PaneRail: View { // MARK: - Glass surfaces +private struct HubGlassKey: EnvironmentKey { static let defaultValue = false } + extension EnvironmentValues { /// On when the hub's glass mode is (`HubGlass`): surfaces let the blurred desktop through. - @Entry var hubGlass = false + var hubGlass: Bool { + get { self[HubGlassKey.self] } + set { self[HubGlassKey.self] = newValue } + } } enum HubSurfaceLevel {