diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 938fee51..b61b9f11 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -2403,3 +2403,5 @@ Repro: open the composer, type ''/'' then a command fragment; (a) include a ''-' Acceptance: ''-'' is treated as a normal command-name character (suggestions keep filtering through hyphens); Tab reliably completes the highlighted suggestion every time (insert via completeSlash). Add/extend unit tests in slash_commands_test.dart for hyphenated queries and a composer widget test for Tab-accept.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-08 09:20:00', '2026-06-09 06:33:31', NULL, 'f3d2e1c7b6efdaf6bc426282cf3275dc', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-29', 'story', 'T-4', 'editor respects .editorconfig settings', 'When a file is opened in the editor, read .editorconfig from the workspace root and apply: indent_style, indent_size, max_line_length (ruler/wrap guide), end_of_line, trim_trailing_whitespace, insert_final_newline. Parse the INI format ourselves (small, no dep). Glob matching per the EditorConfig spec.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-22 20:17:26', '2026-06-09 06:34:31', NULL, '9c6056aa6b5b5eabf0df7792c6c636f6', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-290', 'story', 'T-8', 'Edit editor settings (.editorconfig) from the settings panel', 'Write path for editor settings (T-29 follow-up). The editor now READS .editorconfig into a source-agnostic EditorSettings model (lib/src/editor/editor_settings.dart) and resolves it via editor_settings_resolver.dart. Add a settings-panel surface to EDIT these values and persist them — initially by writing the project .editorconfig (indent_style/size, end_of_line, max_line_length, trim_trailing_whitespace, insert_final_newline), later possibly a clide-owned settings file that merges as a higher-precedence source in resolveEditorSettings. On save the registry already re-resolves open buffers (editor.settings-changed), so the panel edits apply live. Respect EditorConfig semantics (sections/globs); start with the [*] section for the workspace defaults. Refs: T-29, D-88, editor_settings.dart, editor_settings_resolver.dart.', 'backlog', 'medium', NULL, NULL, 'D-88', '2026-06-09 07:15:14', '2026-06-09 07:15:14', NULL, '54bd063a8718b263a316cfac4f23b40e', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-291', 'task', 'T-8', 'Re-resolve .editorconfig on external file changes (fs-watch)', 'T-29 follow-up. Today open buffers re-resolve their EditorSettings when a .editorconfig is saved IN-APP (a hook in EditorRegistry.save → editor.settings-changed). External edits — another editor, a git checkout/branch switch — are NOT picked up until reopen. Wire a filesystem watch so an external .editorconfig change re-resolves open buffers too. Prefer piggybacking the files subsystem''s existing watcher over a new dedicated watch; debounce; only re-resolve buffers under the changed config''s directory. Rare case, hence deferred from the initial T-29 work (the in-app save hook covers the common path). Refs: T-29, lib/src/editor/editor_settings_resolver.dart, EditorRegistry._reresolveSettings.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-09 07:15:22', '2026-06-09 07:15:22', NULL, '032edf9cabf61178afb7d324e3273511', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/lib/src/editor/buffer.dart b/lib/src/editor/buffer.dart index 0fb11abf..1c7fd725 100644 --- a/lib/src/editor/buffer.dart +++ b/lib/src/editor/buffer.dart @@ -6,7 +6,7 @@ /// transitions. library; -import 'editorconfig.dart'; +import 'editor_settings.dart'; class Selection { const Selection({required this.start, required this.end}); @@ -45,7 +45,7 @@ class EditorBuffer { required this.content, Selection? selection, this.dirty = false, - this.editorConfig = EditorConfig.empty, + this.settings = EditorSettings.empty, }) : selection = selection ?? const Selection.collapsed(0); /// Stable daemon-local id (`b_1`, `b_2`, …). @@ -70,10 +70,11 @@ class EditorBuffer { /// `editor.save` (or `files.save` in future). bool dirty; - /// Resolved `.editorconfig` properties for this file (T-29). Drives the - /// editor's indent/ruler rendering on the UI side and the save-time - /// normalization daemon-side. [EditorConfig.empty] when nothing applies. - EditorConfig editorConfig; + /// Effective editor settings for this file (T-29), resolved from its sources + /// (today: `.editorconfig`). Drives the editor's indent/ruler rendering on + /// the UI side and the save-time normalization daemon-side. + /// [EditorSettings.empty] when nothing applies. + EditorSettings settings; Map toJson() => { 'id': id, @@ -81,7 +82,7 @@ class EditorBuffer { 'length': content.length, 'selection': selection.toJson(), 'dirty': dirty, - 'editorConfig': editorConfig.toJson(), + 'editorSettings': settings.toJson(), }; /// Full snapshot including [content] — for `editor.read` / tests / diff --git a/lib/src/editor/editor_settings.dart b/lib/src/editor/editor_settings.dart new file mode 100644 index 00000000..a7a5b223 --- /dev/null +++ b/lib/src/editor/editor_settings.dart @@ -0,0 +1,140 @@ +/// [EditorSettings] — the effective, source-agnostic editor configuration for +/// one file (T-29). +/// +/// The editor surface and the save path obey *this* object, never a particular +/// source file. Today the only source is the project's `.editorconfig` (parsed +/// in `editorconfig.dart`), but a settings panel or a clide-owned settings file +/// can layer in later via [merge] without the editor, registry, or save path +/// changing — that is the whole point of routing everything through one model. +/// +/// Flutter-free (no `dart:io`, no `dart:ui`): it travels from the daemon to the +/// UI as plain JSON and is unit-tested under `dart test`. +library; + +class EditorSettings { + const EditorSettings({ + this.indentStyle, + this.indentSize, + this.tabWidth, + this.endOfLine, + this.maxLineLength, + this.trimTrailingWhitespace, + this.insertFinalNewline, + }); + + /// `tab` or `space`. + final String? indentStyle; + + /// Columns per indent level. + final int? indentSize; + + /// Width of a tab character. + final int? tabWidth; + + /// `lf`, `crlf`, or `cr`. + final String? endOfLine; + + /// Ruler / wrap-guide column. Null when unset. + final int? maxLineLength; + + final bool? trimTrailingWhitespace; + final bool? insertFinalNewline; + + /// Nothing set — the editor keeps all of its built-in defaults. + static const empty = EditorSettings(); + + bool get isEmpty => + indentStyle == null && + indentSize == null && + tabWidth == null && + endOfLine == null && + maxLineLength == null && + trimTrailingWhitespace == null && + insertFinalNewline == null; + + /// The line terminator [endOfLine] names, or null when unset. + String? get eolString => switch (endOfLine) { + 'lf' => '\n', + 'crlf' => '\r\n', + 'cr' => '\r', + _ => null, + }; + + /// The text one Tab press inserts, or null to keep the editor's default + /// (Flutter's focus traversal) — the editor only takes over Tab when a source + /// has an opinion about indentation. + String? get indentUnit { + if (indentStyle == 'tab') return '\t'; + if (indentStyle == 'space') return ' ' * (indentSize ?? 4); + if (indentSize != null) return ' ' * indentSize!; // a size with no style → spaces + return null; + } + + /// Layer [other] on top: every field [other] sets overrides this one, fields + /// it leaves null fall through. The composition order (lowest precedence + /// first) lives in the resolver — a higher-precedence source (settings panel, + /// clide settings file) merges over a lower one (.editorconfig). + EditorSettings merge(EditorSettings other) => EditorSettings( + indentStyle: other.indentStyle ?? indentStyle, + indentSize: other.indentSize ?? indentSize, + tabWidth: other.tabWidth ?? tabWidth, + endOfLine: other.endOfLine ?? endOfLine, + maxLineLength: other.maxLineLength ?? maxLineLength, + trimTrailingWhitespace: other.trimTrailingWhitespace ?? trimTrailingWhitespace, + insertFinalNewline: other.insertFinalNewline ?? insertFinalNewline, + ); + + /// Only the set keys, for the IPC payload. Empty map when [isEmpty]. + Map toJson() => { + if (indentStyle != null) 'indent_style': indentStyle, + if (indentSize != null) 'indent_size': indentSize, + if (tabWidth != null) 'tab_width': tabWidth, + if (endOfLine != null) 'end_of_line': endOfLine, + if (maxLineLength != null) 'max_line_length': maxLineLength, + if (trimTrailingWhitespace != null) 'trim_trailing_whitespace': trimTrailingWhitespace, + if (insertFinalNewline != null) 'insert_final_newline': insertFinalNewline, + }; + + factory EditorSettings.fromJson(Object? raw) { + if (raw is! Map) return empty; + return EditorSettings( + indentStyle: raw['indent_style'] as String?, + indentSize: (raw['indent_size'] as num?)?.toInt(), + tabWidth: (raw['tab_width'] as num?)?.toInt(), + endOfLine: raw['end_of_line'] as String?, + maxLineLength: (raw['max_line_length'] as num?)?.toInt(), + trimTrailingWhitespace: raw['trim_trailing_whitespace'] as bool?, + insertFinalNewline: raw['insert_final_newline'] as bool?, + ); + } + + /// Apply the on-save text fixes these settings request: end-of-line + /// normalization, trailing-whitespace trimming, and final-newline + /// insertion/removal. Returns [content] unchanged where nothing is set. + String applyOnSave(String content) { + if (content.isEmpty) return content; + var out = content; + + // Trim trailing spaces/tabs before any line break or end-of-string. Done + // first and EOL-agnostically so it composes with the EOL rewrite below. + if (trimTrailingWhitespace == true) { + out = out.replaceAll(RegExp(r'[ \t]+(?=\r\n|\r|\n|$)'), ''); + } + + final eol = eolString; + if (eol != null) { + out = out.replaceAll(RegExp(r'\r\n|\r|\n'), eol); + } + + if (insertFinalNewline == true) { + if (out.isNotEmpty && !out.endsWith('\n') && !out.endsWith('\r')) { + out += eol ?? _detectEol(out) ?? '\n'; + } + } else if (insertFinalNewline == false) { + out = out.replaceAll(RegExp(r'(\r\n|\r|\n)+$'), ''); + } + return out; + } + + static String? _detectEol(String s) => RegExp(r'\r\n|\r|\n').firstMatch(s)?.group(0); +} diff --git a/lib/src/editor/editor_settings_resolver.dart b/lib/src/editor/editor_settings_resolver.dart new file mode 100644 index 00000000..c3e63b49 --- /dev/null +++ b/lib/src/editor/editor_settings_resolver.dart @@ -0,0 +1,25 @@ +/// Composes the effective [EditorSettings] for a file from its sources (T-29). +/// +/// This is the single seam the editor stack calls — [EditorRegistry] resolves a +/// buffer's settings here on load and whenever a source changes. Today the only +/// source is the project's `.editorconfig`; a workspace settings file or a +/// settings-panel override layers in by adding another `.merge(...)` below, in +/// increasing-precedence order. The editor and save path never learn the +/// source — they only see the merged result. +library; + +import 'dart:io'; + +import 'editor_settings.dart'; +import 'editorconfig.dart'; + +/// The merged settings for [relPath] (workspace-relative, `/`-separated). +EditorSettings resolveEditorSettings(Directory workspaceRoot, String relPath) { + // Lowest precedence first; later sources override earlier ones. + var settings = EditorSettings.empty; + settings = settings.merge(readEditorConfig(workspaceRoot, relPath)); + // Future sources slot in here, e.g.: + // settings = settings.merge(readWorkspaceSettingsFile(workspaceRoot, relPath)); + // settings = settings.merge(settingsPanelOverrides(relPath)); + return settings; +} diff --git a/lib/src/editor/editorconfig.dart b/lib/src/editor/editorconfig.dart index deaa4686..42c9337a 100644 --- a/lib/src/editor/editorconfig.dart +++ b/lib/src/editor/editorconfig.dart @@ -1,142 +1,33 @@ -/// EditorConfig support (T-29) — read `.editorconfig` from the workspace and -/// resolve the properties that apply to a given file. +/// `.editorconfig` as an [EditorSettings] source (T-29). +/// +/// Reads `.editorconfig` files from the workspace and resolves the properties +/// that apply to a given file into the source-agnostic [EditorSettings] model. +/// This is one *source* feeding `editor_settings_resolver.dart`; it is not the +/// thing the editor obeys directly. /// /// We parse the INI-ish format and match section globs ourselves (no -/// dependency, per the prefer-zero-deps rule). Resolution walks from the file's +/// dependency, per prefer-zero-deps). Resolution walks from the file's /// directory up to the workspace root, honouring `root = true` to stop the /// ascent, with nearer files and later sections winning on conflict — the /// precedence the EditorConfig spec defines. /// /// Flutter-free by construction: this runs daemon-side under `dart test` -/// alongside [EditorRegistry], so it imports only `dart:io` + core. +/// alongside [EditorRegistry], so it imports only `dart:io` + the model. library; import 'dart:io'; -/// The resolved, typed EditorConfig properties for one file. A `null` field -/// means "no opinion" — the editor keeps its default and changes nothing. -class EditorConfig { - const EditorConfig({ - this.indentStyle, - this.indentSize, - this.tabWidth, - this.endOfLine, - this.maxLineLength, - this.trimTrailingWhitespace, - this.insertFinalNewline, - }); +import 'editor_settings.dart'; - /// `tab` or `space`. - final String? indentStyle; - - /// Columns per indent level. Follows [tabWidth] when the file says - /// `indent_size = tab`. - final int? indentSize; - - /// Width of a tab character. - final int? tabWidth; - - /// `lf`, `crlf`, or `cr`. - final String? endOfLine; - - /// Ruler / wrap-guide column. Null when unset or `off`. - final int? maxLineLength; - - final bool? trimTrailingWhitespace; - final bool? insertFinalNewline; - - static const empty = EditorConfig(); - - bool get isEmpty => - indentStyle == null && - indentSize == null && - tabWidth == null && - endOfLine == null && - maxLineLength == null && - trimTrailingWhitespace == null && - insertFinalNewline == null; - - /// The line terminator [endOfLine] names, or null when unset. - String? get eolString => switch (endOfLine) { - 'lf' => '\n', - 'crlf' => '\r\n', - 'cr' => '\r', - _ => null, - }; - - /// Only the set keys, for the IPC buffer payload. Empty map when [isEmpty]. - Map toJson() => { - if (indentStyle != null) 'indent_style': indentStyle, - if (indentSize != null) 'indent_size': indentSize, - if (tabWidth != null) 'tab_width': tabWidth, - if (endOfLine != null) 'end_of_line': endOfLine, - if (maxLineLength != null) 'max_line_length': maxLineLength, - if (trimTrailingWhitespace != null) 'trim_trailing_whitespace': trimTrailingWhitespace, - if (insertFinalNewline != null) 'insert_final_newline': insertFinalNewline, - }; - - /// Build from a merged raw property map (keys already lowercased). Applies - /// the spec's `indent_size`/`tab_width` cross-defaulting. A property whose - /// value is `unset` (or unparseable for its type) resolves to null. - factory EditorConfig.fromProps(Map p) { - String? lc(String k) { - final v = p[k]; - if (v == null) return null; - final t = v.trim().toLowerCase(); - return t == 'unset' ? null : t; - } - - final indentStyle = _oneOf(lc('indent_style'), const {'tab', 'space'}); - int? tabWidth = _posInt(lc('tab_width')); - - final rawIndent = lc('indent_size'); - int? indentSize; - if (rawIndent == 'tab') { - indentSize = tabWidth; - } else { - indentSize = _posInt(rawIndent); - } - // tab_width defaults to indent_size; indent_size (for tabs) defaults to - // tab_width — the reciprocal defaulting from the spec. - tabWidth ??= indentSize; - if (indentStyle == 'tab') indentSize ??= tabWidth; - - final maxRaw = lc('max_line_length'); - final maxLineLength = (maxRaw == 'off') ? null : _posInt(maxRaw); - - return EditorConfig( - indentStyle: indentStyle, - indentSize: indentSize, - tabWidth: tabWidth, - endOfLine: _oneOf(lc('end_of_line'), const {'lf', 'crlf', 'cr'}), - maxLineLength: maxLineLength, - trimTrailingWhitespace: _bool(lc('trim_trailing_whitespace')), - insertFinalNewline: _bool(lc('insert_final_newline')), - ); - } - - static String? _oneOf(String? v, Set allowed) => (v != null && allowed.contains(v)) ? v : null; - static int? _posInt(String? v) { - if (v == null) return null; - final n = int.tryParse(v); - return (n != null && n > 0) ? n : null; - } - - static bool? _bool(String? v) => switch (v) { - 'true' => true, - 'false' => false, - _ => null, - }; -} - -/// Resolve the EditorConfig for [relPath] (a workspace-relative, `/`-separated -/// path) against the `.editorconfig` files under [workspaceRoot]. +/// Resolve the EditorConfig-sourced [EditorSettings] for [relPath] (a +/// workspace-relative, `/`-separated path) against the `.editorconfig` files +/// under [workspaceRoot]. /// /// Walks from the file's directory up to (and including) the workspace root, /// stopping once a file declares `root = true`. Never throws — an unreadable or /// malformed file is skipped, so a broken `.editorconfig` can't wedge a file /// open or save. -EditorConfig resolveEditorConfig(Directory workspaceRoot, String relPath) { +EditorSettings readEditorConfig(Directory workspaceRoot, String relPath) { final rel = relPath.replaceAll('\\', '/').replaceAll(RegExp(r'^/+'), ''); final segs = rel.split('/'); final dirSegs = segs.sublist(0, segs.length - 1); @@ -170,42 +61,63 @@ EditorConfig resolveEditorConfig(Directory workspaceRoot, String relPath) { } } } - return EditorConfig.fromProps(props); + return editorSettingsFromProps(props); } -/// Apply the on-save text fixes [cfg] requests: end-of-line normalization, -/// trailing-whitespace trimming, and final-newline insertion/removal. Returns -/// the content unchanged where [cfg] has no opinion. -String applyEditorConfigOnSave(String content, EditorConfig cfg) { - if (content.isEmpty) return content; - var out = content; - - // Trim trailing spaces/tabs before any line break or end-of-string. Done - // first and EOL-agnostically so it composes with the EOL rewrite below. - if (cfg.trimTrailingWhitespace == true) { - out = out.replaceAll(RegExp(r'[ \t]+(?=\r\n|\r|\n|$)'), ''); +/// Build [EditorSettings] from a merged raw EditorConfig property map (keys +/// already lowercased). Applies the spec's `indent_size`/`tab_width` +/// cross-defaulting; a property valued `unset` (or unparseable for its type) +/// resolves to null. Public for direct unit testing of the mapping. +EditorSettings editorSettingsFromProps(Map p) { + String? lc(String k) { + final v = p[k]; + if (v == null) return null; + final t = v.trim().toLowerCase(); + return t == 'unset' ? null : t; } - final eol = cfg.eolString; - if (eol != null) { - out = out.replaceAll(RegExp(r'\r\n|\r|\n'), eol); - } + final indentStyle = _oneOf(lc('indent_style'), const {'tab', 'space'}); + int? tabWidth = _posInt(lc('tab_width')); - if (cfg.insertFinalNewline == true) { - if (out.isNotEmpty && !out.endsWith('\n') && !out.endsWith('\r')) { - out += eol ?? _detectEol(out) ?? '\n'; - } - } else if (cfg.insertFinalNewline == false) { - out = out.replaceAll(RegExp(r'(\r\n|\r|\n)+$'), ''); + final rawIndent = lc('indent_size'); + int? indentSize; + if (rawIndent == 'tab') { + indentSize = tabWidth; + } else { + indentSize = _posInt(rawIndent); } - return out; + // tab_width defaults to indent_size; indent_size (for tabs) defaults to + // tab_width — the reciprocal defaulting from the spec. + tabWidth ??= indentSize; + if (indentStyle == 'tab') indentSize ??= tabWidth; + + final maxRaw = lc('max_line_length'); + final maxLineLength = (maxRaw == 'off') ? null : _posInt(maxRaw); + + return EditorSettings( + indentStyle: indentStyle, + indentSize: indentSize, + tabWidth: tabWidth, + endOfLine: _oneOf(lc('end_of_line'), const {'lf', 'crlf', 'cr'}), + maxLineLength: maxLineLength, + trimTrailingWhitespace: _bool(lc('trim_trailing_whitespace')), + insertFinalNewline: _bool(lc('insert_final_newline')), + ); } -String? _detectEol(String s) { - final m = RegExp(r'\r\n|\r|\n').firstMatch(s); - return m?.group(0); +String? _oneOf(String? v, Set allowed) => (v != null && allowed.contains(v)) ? v : null; +int? _posInt(String? v) { + if (v == null) return null; + final n = int.tryParse(v); + return (n != null && n > 0) ? n : null; } +bool? _bool(String? v) => switch (v) { + 'true' => true, + 'false' => false, + _ => null, + }; + // --------------------------------------------------------------------------- // INI parsing // --------------------------------------------------------------------------- diff --git a/lib/src/editor/registry.dart b/lib/src/editor/registry.dart index bdf6f58f..9cb564d0 100644 --- a/lib/src/editor/registry.dart +++ b/lib/src/editor/registry.dart @@ -12,7 +12,7 @@ import 'dart:io'; import '../ipc/envelope.dart'; import '../panes/event_sink.dart'; import 'buffer.dart'; -import 'editorconfig.dart'; +import 'editor_settings_resolver.dart'; class EditorRegistry { EditorRegistry({ @@ -57,7 +57,7 @@ class EditorRegistry { id: id, path: path, content: content, - editorConfig: resolveEditorConfig(workspaceRoot, path), + settings: resolveEditorSettings(workspaceRoot, path), ); _buffers[id] = buf; _pathToId[path] = id; @@ -150,15 +150,16 @@ class EditorRegistry { }); } - /// Persist [id] to disk. Applies the buffer's `.editorconfig` save fixes - /// (EOL, trailing-whitespace, final-newline) first, and — when those changed - /// the text — reconciles the in-memory buffer + UI so disk and buffer agree. - /// Clears the dirty flag on success. + /// Persist [id] to disk. Applies the buffer's on-save settings (EOL, + /// trailing-whitespace, final-newline) first, and — when those changed the + /// text — reconciles the in-memory buffer + UI so disk and buffer agree. + /// Clears the dirty flag on success. Saving a `.editorconfig` re-resolves the + /// settings of every open buffer (its rules just changed). Future save(String id) async { final buf = _buffers[id]; if (buf == null) return false; - final normalized = applyEditorConfigOnSave(buf.content, buf.editorConfig); + final normalized = buf.settings.applyOnSave(buf.content); final changed = normalized != buf.content; final absolute = _absolutePathOf(buf.path); @@ -183,9 +184,29 @@ class EditorRegistry { buf.dirty = false; _emit('editor.saved', {'id': id, 'path': buf.path}); + + if (_isEditorConfigPath(buf.path)) _reresolveSettings(); return true; } + /// Recompute every open buffer's effective settings from its sources and tell + /// the UI about the ones that changed. Called when a `.editorconfig` is saved + /// in-app (the file's rules changed under the open buffers). + void _reresolveSettings() { + for (final buf in _buffers.values) { + final next = resolveEditorSettings(workspaceRoot, buf.path); + if (next.toJson().toString() == buf.settings.toJson().toString()) continue; + buf.settings = next; + _emit('editor.settings-changed', { + 'id': buf.id, + 'path': buf.path, + 'editorSettings': next.toJson(), + }); + } + } + + bool _isEditorConfigPath(String path) => path == '.editorconfig' || path.endsWith('/.editorconfig'); + /// Close a buffer. Idempotent. void close(String id) { final buf = _buffers.remove(id); diff --git a/test/editor/editor_settings_resolver_test.dart b/test/editor/editor_settings_resolver_test.dart new file mode 100644 index 00000000..53f0ce16 --- /dev/null +++ b/test/editor/editor_settings_resolver_test.dart @@ -0,0 +1,23 @@ +import 'dart:io'; + +import 'package:clide/src/editor/editor_settings_resolver.dart'; +import 'package:test/test.dart'; + +void main() { + group('resolveEditorSettings', () { + late Directory root; + setUp(() async => root = await Directory.systemTemp.createTemp('clide-ec-resolve-')); + tearDown(() => root.deleteSync(recursive: true)); + + test('composes from the .editorconfig source', () async { + await File('${root.path}/.editorconfig').writeAsString('root = true\n[*]\nindent_style = space\nindent_size = 2\n'); + final s = resolveEditorSettings(root, 'lib/a.dart'); + expect(s.indentStyle, 'space'); + expect(s.indentSize, 2); + }); + + test('no source resolves to empty settings', () async { + expect(resolveEditorSettings(root, 'a.txt').isEmpty, isTrue); + }); + }); +} diff --git a/test/editor/editor_settings_test.dart b/test/editor/editor_settings_test.dart new file mode 100644 index 00000000..18354142 --- /dev/null +++ b/test/editor/editor_settings_test.dart @@ -0,0 +1,81 @@ +import 'package:clide/src/editor/editor_settings.dart'; +import 'package:test/test.dart'; + +void main() { + group('EditorSettings model', () { + test('toJson omits unset keys; fromJson round-trips', () { + expect(const EditorSettings().toJson(), isEmpty); + const s = EditorSettings(indentStyle: 'space', indentSize: 2, maxLineLength: 80, insertFinalNewline: true); + expect(EditorSettings.fromJson(s.toJson()).toJson(), s.toJson()); + expect(EditorSettings.fromJson('not a map'), EditorSettings.empty); + }); + + test('isEmpty + eolString', () { + expect(EditorSettings.empty.isEmpty, isTrue); + expect(const EditorSettings(indentSize: 2).isEmpty, isFalse); + expect(const EditorSettings(endOfLine: 'crlf').eolString, '\r\n'); + expect(const EditorSettings(endOfLine: 'lf').eolString, '\n'); + expect(const EditorSettings(endOfLine: 'cr').eolString, '\r'); + expect(EditorSettings.empty.eolString, isNull); + }); + + test('indentUnit reflects style + size', () { + expect(const EditorSettings(indentStyle: 'tab').indentUnit, '\t'); + expect(const EditorSettings(indentStyle: 'space', indentSize: 3).indentUnit, ' '); + expect(const EditorSettings(indentStyle: 'space').indentUnit, ' '); // defaults to 4 + expect(const EditorSettings(indentSize: 2).indentUnit, ' '); // size with no style → spaces + expect(EditorSettings.empty.indentUnit, isNull); // no opinion → keep default + }); + + test('merge — later source wins per set field, others fall through', () { + const base = EditorSettings(indentStyle: 'space', indentSize: 2, maxLineLength: 80); + const over = EditorSettings(indentSize: 4, insertFinalNewline: true); + final m = base.merge(over); + expect(m.indentSize, 4); // overridden + expect(m.indentStyle, 'space'); // inherited + expect(m.maxLineLength, 80); // inherited + expect(m.insertFinalNewline, isTrue); // added + // Merging empty over a value is a no-op; value over empty adopts it. + expect(base.merge(EditorSettings.empty).toJson(), base.toJson()); + expect(EditorSettings.empty.merge(base).toJson(), base.toJson()); + }); + }); + + group('EditorSettings.applyOnSave', () { + test('trims trailing whitespace on every line, any EOL', () { + const cfg = EditorSettings(trimTrailingWhitespace: true); + expect(cfg.applyOnSave('a \nb\t\nc'), 'a\nb\nc'); + expect(cfg.applyOnSave('a \r\nb \r\n'), 'a\r\nb\r\n'); + }); + + test('inserts a final newline when missing', () { + const cfg = EditorSettings(insertFinalNewline: true); + expect(cfg.applyOnSave('abc'), 'abc\n'); + expect(cfg.applyOnSave('abc\n'), 'abc\n'); + }); + + test('insert_final_newline=false strips trailing newlines', () { + expect(const EditorSettings(insertFinalNewline: false).applyOnSave('abc\n\n'), 'abc'); + }); + + test('final newline uses the configured EOL, else the detected one', () { + expect(const EditorSettings(endOfLine: 'crlf', insertFinalNewline: true).applyOnSave('abc'), 'abc\r\n'); + expect(const EditorSettings(insertFinalNewline: true).applyOnSave('a\r\nb'), 'a\r\nb\r\n'); + }); + + test('normalizes EOL across the whole file', () { + expect(const EditorSettings(endOfLine: 'crlf').applyOnSave('a\nb\nc'), 'a\r\nb\r\nc'); + expect(const EditorSettings(endOfLine: 'lf').applyOnSave('a\r\nb\r\n'), 'a\nb\n'); + }); + + test('no opinion (and empty content) leaves the text untouched', () { + expect(EditorSettings.empty.applyOnSave('a \r\nb'), 'a \r\nb'); + expect(const EditorSettings(insertFinalNewline: true).applyOnSave(''), ''); + }); + + test('composes trim + EOL + final newline in order', () { + const cfg = EditorSettings(trimTrailingWhitespace: true, endOfLine: 'lf', insertFinalNewline: true); + expect(cfg.applyOnSave('a \r\nb\t'), 'a\nb\n'); + }); + }); +} diff --git a/test/editor/editorconfig_test.dart b/test/editor/editorconfig_test.dart index 0826b966..26fb7114 100644 --- a/test/editor/editorconfig_test.dart +++ b/test/editor/editorconfig_test.dart @@ -4,9 +4,9 @@ import 'package:clide/src/editor/editorconfig.dart'; import 'package:test/test.dart'; void main() { - group('EditorConfig.fromProps', () { - test('parses the common indent + newline keys', () { - final c = EditorConfig.fromProps({ + group('editorSettingsFromProps', () { + test('maps the common indent + newline keys', () { + final c = editorSettingsFromProps({ 'indent_style': 'space', 'indent_size': '2', 'end_of_line': 'lf', @@ -24,83 +24,31 @@ void main() { }); test('indent_size = tab follows tab_width', () { - final c = EditorConfig.fromProps({'indent_style': 'tab', 'indent_size': 'tab', 'tab_width': '4'}); + final c = editorSettingsFromProps({'indent_style': 'tab', 'indent_size': 'tab', 'tab_width': '4'}); expect(c.indentSize, 4); expect(c.tabWidth, 4); }); test('tab indent with no indent_size defaults to tab_width', () { - final c = EditorConfig.fromProps({'indent_style': 'tab', 'tab_width': '8'}); - expect(c.indentSize, 8); + expect(editorSettingsFromProps({'indent_style': 'tab', 'tab_width': '8'}).indentSize, 8); }); test('max_line_length = off and unset values resolve to null', () { - final c = EditorConfig.fromProps({'max_line_length': 'off', 'indent_style': 'unset', 'insert_final_newline': 'unset'}); + final c = editorSettingsFromProps({'max_line_length': 'off', 'indent_style': 'unset', 'insert_final_newline': 'unset'}); expect(c.maxLineLength, isNull); expect(c.indentStyle, isNull); expect(c.insertFinalNewline, isNull); }); test('rejects junk values rather than guessing', () { - final c = EditorConfig.fromProps({'indent_style': 'tabs', 'indent_size': '-3', 'end_of_line': 'mac'}); + final c = editorSettingsFromProps({'indent_style': 'tabs', 'indent_size': '-3', 'end_of_line': 'mac'}); expect(c.indentStyle, isNull); expect(c.indentSize, isNull); expect(c.endOfLine, isNull); }); - - test('toJson omits unset keys', () { - expect(const EditorConfig().toJson(), isEmpty); - expect(const EditorConfig(indentStyle: 'space', indentSize: 2).toJson(), {'indent_style': 'space', 'indent_size': 2}); - }); }); - group('applyEditorConfigOnSave', () { - test('trims trailing whitespace on every line, any EOL', () { - const cfg = EditorConfig(trimTrailingWhitespace: true); - expect(applyEditorConfigOnSave('a \nb\t\nc', cfg), 'a\nb\nc'); - expect(applyEditorConfigOnSave('a \r\nb \r\n', cfg), 'a\r\nb\r\n'); - }); - - test('inserts a final newline when missing', () { - const cfg = EditorConfig(insertFinalNewline: true); - expect(applyEditorConfigOnSave('abc', cfg), 'abc\n'); - expect(applyEditorConfigOnSave('abc\n', cfg), 'abc\n'); // already present - }); - - test('insert_final_newline=false strips trailing newlines', () { - const cfg = EditorConfig(insertFinalNewline: false); - expect(applyEditorConfigOnSave('abc\n\n', cfg), 'abc'); - }); - - test('final newline uses the configured EOL', () { - const cfg = EditorConfig(endOfLine: 'crlf', insertFinalNewline: true); - expect(applyEditorConfigOnSave('abc', cfg), 'abc\r\n'); - }); - - test('normalizes EOL across the whole file', () { - const cfg = EditorConfig(endOfLine: 'crlf'); - expect(applyEditorConfigOnSave('a\nb\nc', cfg), 'a\r\nb\r\nc'); - const lf = EditorConfig(endOfLine: 'lf'); - expect(applyEditorConfigOnSave('a\r\nb\r\n', lf), 'a\nb\n'); - }); - - test('no opinion leaves the content untouched', () { - expect(applyEditorConfigOnSave('a \r\nb', EditorConfig.empty), 'a \r\nb'); - expect(applyEditorConfigOnSave('', const EditorConfig(insertFinalNewline: true)), ''); - }); - - test('with no EOL set, the inserted final newline matches the file style', () { - const cfg = EditorConfig(insertFinalNewline: true); - expect(applyEditorConfigOnSave('a\r\nb', cfg), 'a\r\nb\r\n'); // detected crlf - }); - - test('composes trim + EOL + final newline in order', () { - const cfg = EditorConfig(trimTrailingWhitespace: true, endOfLine: 'lf', insertFinalNewline: true); - expect(applyEditorConfigOnSave('a \r\nb\t', cfg), 'a\nb\n'); - }); - }); - - group('resolveEditorConfig — glob matching', () { + group('readEditorConfig — glob matching', () { late Directory root; setUp(() async => root = await Directory.systemTemp.createTemp('clide-ec-glob-')); tearDown(() => root.deleteSync(recursive: true)); @@ -113,88 +61,80 @@ void main() { test('[*] applies to every file', () async { await write('.editorconfig', 'root = true\n[*]\nindent_size = 2\n'); - expect(resolveEditorConfig(root, 'a.txt').indentSize, 2); - expect(resolveEditorConfig(root, 'sub/b.dart').indentSize, 2); + expect(readEditorConfig(root, 'a.txt').indentSize, 2); + expect(readEditorConfig(root, 'sub/b.dart').indentSize, 2); }); test('extension globs and brace alternation', () async { await write('.editorconfig', 'root = true\n[*.dart]\nindent_size = 2\n[*.{js,ts}]\nindent_size = 4\n'); - expect(resolveEditorConfig(root, 'main.dart').indentSize, 2); - expect(resolveEditorConfig(root, 'app.js').indentSize, 4); - expect(resolveEditorConfig(root, 'app.ts').indentSize, 4); - expect(resolveEditorConfig(root, 'readme.md').indentSize, isNull); + expect(readEditorConfig(root, 'main.dart').indentSize, 2); + expect(readEditorConfig(root, 'app.js').indentSize, 4); + expect(readEditorConfig(root, 'app.ts').indentSize, 4); + expect(readEditorConfig(root, 'readme.md').indentSize, isNull); }); test('a slash anchors the glob to the config dir; ** spans dirs', () async { await write('.editorconfig', 'root = true\n[lib/**.dart]\nindent_size = 3\n'); - expect(resolveEditorConfig(root, 'lib/a.dart').indentSize, 3); - expect(resolveEditorConfig(root, 'lib/deep/b.dart').indentSize, 3); - expect(resolveEditorConfig(root, 'test/c.dart').indentSize, isNull); - }); - - test('character class and negation', () async { - await write('.editorconfig', 'root = true\n[[a-c].txt]\nindent_size = 5\n[!x].md]\nindent_size = 6\n'); - expect(resolveEditorConfig(root, 'b.txt').indentSize, 5); - expect(resolveEditorConfig(root, 'z.txt').indentSize, isNull); - }); - - test('numeric range', () async { - await write('.editorconfig', 'root = true\n[file{1..3}.txt]\nindent_size = 7\n'); - expect(resolveEditorConfig(root, 'file2.txt').indentSize, 7); - expect(resolveEditorConfig(root, 'file9.txt').indentSize, isNull); + expect(readEditorConfig(root, 'lib/a.dart').indentSize, 3); + expect(readEditorConfig(root, 'lib/deep/b.dart').indentSize, 3); + expect(readEditorConfig(root, 'test/c.dart').indentSize, isNull); }); test('? matches exactly one non-separator char', () async { await write('.editorconfig', 'root = true\n[?.txt]\nindent_size = 9\n'); - expect(resolveEditorConfig(root, 'a.txt').indentSize, 9); - expect(resolveEditorConfig(root, 'ab.txt').indentSize, isNull); + expect(readEditorConfig(root, 'a.txt').indentSize, 9); + expect(readEditorConfig(root, 'ab.txt').indentSize, isNull); }); test('leading **/ matches in the config dir and below', () async { await write('.editorconfig', 'root = true\n[**/foo.txt]\nindent_size = 3\n'); - expect(resolveEditorConfig(root, 'foo.txt').indentSize, 3); - expect(resolveEditorConfig(root, 'a/b/foo.txt').indentSize, 3); + expect(readEditorConfig(root, 'foo.txt').indentSize, 3); + expect(readEditorConfig(root, 'a/b/foo.txt').indentSize, 3); }); test('negated character class', () async { await write('.editorconfig', 'root = true\n[[!a-c].txt]\nindent_size = 4\n'); - expect(resolveEditorConfig(root, 'z.txt').indentSize, 4); - expect(resolveEditorConfig(root, 'b.txt').indentSize, isNull); + expect(readEditorConfig(root, 'z.txt').indentSize, 4); + expect(readEditorConfig(root, 'b.txt').indentSize, isNull); + }); + + test('numeric range', () async { + await write('.editorconfig', 'root = true\n[file{1..3}.txt]\nindent_size = 7\n'); + expect(readEditorConfig(root, 'file2.txt').indentSize, 7); + expect(readEditorConfig(root, 'file9.txt').indentSize, isNull); }); test('an oversized numeric range falls back to a generic integer match', () async { await write('.editorconfig', 'root = true\n[v{1..5000}]\nindent_size = 6\n'); - expect(resolveEditorConfig(root, 'v123').indentSize, 6); - expect(resolveEditorConfig(root, 'vx').indentSize, isNull); + expect(readEditorConfig(root, 'v123').indentSize, 6); + expect(readEditorConfig(root, 'vx').indentSize, isNull); }); test('a brace with no top-level comma and an unterminated class are literal', () async { await write('.editorconfig', 'root = true\n[a{b}.txt]\nindent_size = 2\n[lit[.md]\nindent_size = 8\n'); - // {b} is a literal brace group → matches the literal text "a{b}.txt". - expect(resolveEditorConfig(root, 'a{b}.txt').indentSize, 2); - // "[lit[.md" has an unterminated class → the leading [ is literal. - expect(resolveEditorConfig(root, 'lit[.md').indentSize, 8); + expect(readEditorConfig(root, 'a{b}.txt').indentSize, 2); + expect(readEditorConfig(root, 'lit[.md').indentSize, 8); }); test('backslash escapes a glob metacharacter to a literal', () async { await write('.editorconfig', 'root = true\n[a\\{b.txt]\nindent_size = 5\n'); - expect(resolveEditorConfig(root, 'a{b.txt').indentSize, 5); - expect(resolveEditorConfig(root, 'aXb.txt').indentSize, isNull); + expect(readEditorConfig(root, 'a{b.txt').indentSize, 5); + expect(readEditorConfig(root, 'aXb.txt').indentSize, isNull); }); test('an unterminated brace (even with an escaped }) is literal', () async { await write('.editorconfig', 'root = true\n[x{a\\}b.txt]\nindent_size = 6\n'); - expect(resolveEditorConfig(root, 'x{a}b.txt').indentSize, 6); + expect(readEditorConfig(root, 'x{a}b.txt').indentSize, 6); }); test('escaped chars inside a class and inside an alternation', () async { await write('.editorconfig', 'root = true\n[[a\\-c].txt]\nindent_size = 3\n[{a\\,b,c}.md]\nindent_size = 4\n'); - expect(resolveEditorConfig(root, 'c.txt').indentSize, 3); // c is in the class - expect(resolveEditorConfig(root, 'c.md').indentSize, 4); // 'c' alternative + expect(readEditorConfig(root, 'c.txt').indentSize, 3); + expect(readEditorConfig(root, 'c.md').indentSize, 4); }); }); - group('resolveEditorConfig — precedence', () { + group('readEditorConfig — precedence', () { late Directory root; setUp(() async => root = await Directory.systemTemp.createTemp('clide-ec-prec-')); tearDown(() => root.deleteSync(recursive: true)); @@ -208,43 +148,42 @@ void main() { test('a nearer config overrides a farther one', () async { await write('.editorconfig', 'root = true\n[*]\nindent_size = 2\nindent_style = space\n'); await write('sub/.editorconfig', '[*]\nindent_size = 4\n'); - final c = resolveEditorConfig(root, 'sub/x.dart'); - expect(c.indentSize, 4); // overridden by nearer - expect(c.indentStyle, 'space'); // inherited from farther + final c = readEditorConfig(root, 'sub/x.dart'); + expect(c.indentSize, 4); + expect(c.indentStyle, 'space'); }); test('root = true halts the ascent', () async { - await write('.editorconfig', '[*]\nindent_style = tab\n'); // would apply if reached + await write('.editorconfig', '[*]\nindent_style = tab\n'); await write('sub/.editorconfig', 'root = true\n[*]\nindent_size = 4\n'); - final c = resolveEditorConfig(root, 'sub/x.dart'); + final c = readEditorConfig(root, 'sub/x.dart'); expect(c.indentSize, 4); - expect(c.indentStyle, isNull); // top-level file never consulted + expect(c.indentStyle, isNull); }); test('later sections in one file win', () async { await write('.editorconfig', 'root = true\n[*]\nindent_size = 2\n[*.dart]\nindent_size = 4\n'); - expect(resolveEditorConfig(root, 'a.dart').indentSize, 4); + expect(readEditorConfig(root, 'a.dart').indentSize, 4); }); test('a nearer "unset" clears an inherited property', () async { await write('.editorconfig', 'root = true\n[*]\nindent_style = space\n'); await write('sub/.editorconfig', '[*]\nindent_style = unset\n'); - expect(resolveEditorConfig(root, 'sub/x.txt').indentStyle, isNull); + expect(readEditorConfig(root, 'sub/x.txt').indentStyle, isNull); }); test('no .editorconfig anywhere resolves to empty', () async { - expect(resolveEditorConfig(root, 'a.txt').isEmpty, isTrue); + expect(readEditorConfig(root, 'a.txt').isEmpty, isTrue); }); test('a malformed file is skipped, not fatal', () async { await write('.editorconfig', 'root = true\n[*\nindent_size = 2\nnonsense line\n[*]\nindent_size = 8\n'); - // The broken section header is ignored; the valid [*] still applies. - expect(resolveEditorConfig(root, 'a.txt').indentSize, 8); + expect(readEditorConfig(root, 'a.txt').indentSize, 8); }); test('comments (# and ;) are ignored', () async { await write('.editorconfig', '# top comment\nroot = true\n[*]\n; inline note\nindent_size = 2\n'); - expect(resolveEditorConfig(root, 'a.txt').indentSize, 2); + expect(readEditorConfig(root, 'a.txt').indentSize, 2); }); }); } diff --git a/test/editor/registry_test.dart b/test/editor/registry_test.dart index 00a95734..66f52c3c 100644 --- a/test/editor/registry_test.dart +++ b/test/editor/registry_test.dart @@ -153,13 +153,13 @@ void main() { }); group('.editorconfig (T-29)', () { - test('open resolves the editorconfig for the file', () async { + test('open resolves the settings for the file', () async { await File('${sandbox.path}/.editorconfig').writeAsString('root = true\n[*]\nindent_style = space\nindent_size = 2\n'); final buf = await reg.open('README.md'); - expect(buf.editorConfig.indentStyle, 'space'); - expect(buf.editorConfig.indentSize, 2); + expect(buf.settings.indentStyle, 'space'); + expect(buf.settings.indentSize, 2); // Exposed over IPC for the UI. - expect(buf.toJson()['editorConfig'], {'indent_style': 'space', 'indent_size': 2, 'tab_width': 2}); + expect(buf.toJson()['editorSettings'], {'indent_style': 'space', 'indent_size': 2, 'tab_width': 2}); }); test('save trims trailing whitespace + adds a final newline on disk', () async { @@ -197,5 +197,22 @@ void main() { expect(sink.ofKind('editor.edited'), isEmpty); // nothing to reconcile expect(sink.ofKind('editor.saved'), hasLength(1)); }); + + test('saving a .editorconfig re-resolves open buffers and notifies', () async { + // README opens with no rules in effect. + final readme = await reg.open('README.md'); + expect(readme.settings.indentSize, isNull); + + // Author a .editorconfig in the editor and save it. + final cfg = await reg.open('.editorconfig'); + reg.setContent(cfg.id, 'root = true\n[*]\nindent_size = 4\n'); + sink.events.clear(); + await reg.save(cfg.id); + + // The open README picks up the new rules without reopening. + expect(readme.settings.indentSize, 4); + final changed = sink.ofKind('editor.settings-changed'); + expect(changed.map((e) => e.data['id']), contains(readme.id)); + }); }); }