From 55c66601c322b34027c7d9665e56a53d8b2f82e6 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 9 Jun 2026 08:48:47 +0200 Subject: [PATCH] read .editorconfig and apply it on open + save (T-29) New lib/src/editor/editorconfig.dart: a dependency-free INI parser, an EditorConfig-flavour glob matcher (*, **, ?, [seq], {a,b}, {m..n}), and directory-walk resolution honouring root=true with nearest-file / last-section precedence. EditorRegistry resolves the config when a buffer opens (exposed on the buffer JSON for the UI) and applies end_of_line / trim_trailing_whitespace / insert_final_newline on save, reconciling the in-memory buffer when the text changes. 100% line coverage on the new file. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 2 + .pql/changelog/tickets/2026-06.sql | 10 + CHANGELOG.md | 6 + lib/src/editor/buffer.dart | 9 + lib/src/editor/editorconfig.dart | 446 ++++++++++++++++++++++ lib/src/editor/registry.dart | 37 +- test/editor/editorconfig_test.dart | 250 ++++++++++++ test/editor/registry_test.dart | 47 +++ 8 files changed, 804 insertions(+), 3 deletions(-) create mode 100644 lib/src/editor/editorconfig.dart create mode 100644 test/editor/editorconfig_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index c5d91f44..4ffdfd46 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -944,3 +944,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, DONE: Primitive (ClideAnchoredOverlay + ClideMenu + ClideMenuListController + ClideTypeahead) shipped and exported. Migrated: T-275 permission picker, menu bar, theme picker (ClideMenu), @-mention + slash typeaheads (ClideTypeahead). Quick-open intentionally left bespoke (centred persistent widget, no shared shape) — see D-88 closing amendment. Modal session/project/branch pickers stay on DialogRouter by design.', NULL, '2026-06-09 06:19:33', '2026-06-09 06:19:33', '2026-06-09 06:19:33', NULL, 'b06e0da4e5e9034b278ce6ee038d9735', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-286', 'status', 'in_progress', 'done', NULL, '2026-06-09 06:19:36', '2026-06-09 06:19:36', '2026-06-09 06:19:36', NULL, '758a6290901bf0cea2725db0269c6a68', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-278', 'status', 'backlog', 'ready', NULL, '2026-06-09 06:33:31', '2026-06-09 06:33:31', '2026-06-09 06:33:31', NULL, 'a2ce57ac3dd7c550d5bd3ef6de579e31', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-29', 'status', 'ready', 'in_progress', NULL, '2026-06-09 06:34:31', '2026-06-09 06:34:31', '2026-06-09 06:34:31', NULL, '58f6b717659a5cdba9de5af300add3be', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 784b3faf..938fee51 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -2393,3 +2393,13 @@ DONE: Primitive (ClideAnchoredOverlay + ClideMenu + ClideMenuListController + Cl 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-286', 'epic', 'T-276', 'Anchored popover + menu primitive; migrate anchored surfaces', 'Extract a clide-owned anchored-popover + menu widget primitive (lib/widgets/, no Material) and migrate every anchored surface onto it. Nine surfaces hand-roll the same anchored-overlay + row-list + barrier + keyboard-nav pattern. Two layers: ClideAnchoredOverlay (positioning/lifecycle: LayerLink/follower or centered, barrier, Esc, focus capture, autoFlip) + ClideMenu / ClideMenuListController (rows + reusable nav). Modal DialogRouter pickers (session/project/branch) stay modal. Built on T-275''s permission-mode picker first, then migrate menu bar, theme picker, @-mention, slash typeahead, quick-open. See decision (architecture domain) + plan. Children: primitive, T-275 picker, one per migration. DONE: Primitive (ClideAnchoredOverlay + ClideMenu + ClideMenuListController + ClideTypeahead) shipped and exported. Migrated: T-275 permission picker, menu bar, theme picker (ClideMenu), @-mention + slash typeaheads (ClideTypeahead). Quick-open intentionally left bespoke (centred persistent widget, no shared shape) — see D-88 closing amendment. Modal session/project/branch pickers stay on DialogRouter by design.', 'done', 'medium', NULL, NULL, NULL, '2026-06-08 16:30:27', '2026-06-09 06:19:36', NULL, 'e6c9a33aa6d9ca9802fc93bd2b526845', 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-278', 'bug', 'T-276', 'Slash-command typeahead breaks on ''-''; Tab-to-complete is flaky', 'Two issues in the Claude composer slash-command typeahead (T-152/T-153, lib/builtin/claude/src/slash_commands.dart + claude_composer.dart): + +1. Typing a ''-'' character breaks the typeahead. Many command names contain hyphens (e.g. clear-context-style names, custom commands), but typing ''-'' appears to drop/empty the suggestion list or mis-parse the active query. Suspect activeSlashQuery''s token run or filterSlashCommands prefix matching not handling ''-'' (or the composer treating ''-'' as a boundary). + +2. Tab to accept the highlighted suggestion responds flakily — sometimes it completes, sometimes nothing happens. Suspect a focus/key-handling race between the composer''s key handler and the typeahead overlay, or Tab being consumed by focus traversal before the accept intent fires. + +Repro: open the composer, type ''/'' then a command fragment; (a) include a ''-'' and watch the list; (b) arrow-select an item and press Tab repeatedly. + +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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 6504b45a..cc0e4f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **The editor reads `.editorconfig`.** Opening a file resolves the + workspace's `.editorconfig` rules (own INI parser + glob matcher, directory + walk with `root = true` and nearest-wins precedence — no new dependency), and + saving applies `end_of_line`, `trim_trailing_whitespace`, and + `insert_final_newline`. The resolved indent/ruler settings ride along on the + buffer for the editor surface to honour. (T-29) - **Permission-mode control beside the Claude composer.** An icon-only, per-mode-coloured button opens a menu of the safe modes (default · accept-edits · plan); `bypass` shows disabled. The status-bar mode is now a diff --git a/lib/src/editor/buffer.dart b/lib/src/editor/buffer.dart index 4c22e150..0fb11abf 100644 --- a/lib/src/editor/buffer.dart +++ b/lib/src/editor/buffer.dart @@ -6,6 +6,8 @@ /// transitions. library; +import 'editorconfig.dart'; + class Selection { const Selection({required this.start, required this.end}); @@ -43,6 +45,7 @@ class EditorBuffer { required this.content, Selection? selection, this.dirty = false, + this.editorConfig = EditorConfig.empty, }) : selection = selection ?? const Selection.collapsed(0); /// Stable daemon-local id (`b_1`, `b_2`, …). @@ -67,12 +70,18 @@ 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; + Map toJson() => { 'id': id, 'path': path, 'length': content.length, 'selection': selection.toJson(), 'dirty': dirty, + 'editorConfig': editorConfig.toJson(), }; /// Full snapshot including [content] — for `editor.read` / tests / diff --git a/lib/src/editor/editorconfig.dart b/lib/src/editor/editorconfig.dart new file mode 100644 index 00000000..deaa4686 --- /dev/null +++ b/lib/src/editor/editorconfig.dart @@ -0,0 +1,446 @@ +/// EditorConfig support (T-29) — read `.editorconfig` from the workspace and +/// resolve the properties that apply to a given file. +/// +/// 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 +/// 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. +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, + }); + + /// `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]. +/// +/// 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) { + final rel = relPath.replaceAll('\\', '/').replaceAll(RegExp(r'^/+'), ''); + final segs = rel.split('/'); + final dirSegs = segs.sublist(0, segs.length - 1); + final sep = Platform.pathSeparator; + + // Nearest-first, so we can stop the ascent at root=true. + final collected = <_ConfigFile>[]; + for (var k = dirSegs.length; k >= 0; k--) { + final dirSegments = dirSegs.sublist(0, k); + final filePath = [workspaceRoot.path, ...dirSegments, '.editorconfig'].join(sep); + final file = File(filePath); + if (!file.existsSync()) continue; + _ConfigFile parsed; + try { + // The path of the queried file relative to THIS .editorconfig's dir. + parsed = _parseIni(file.readAsStringSync(), segs.sublist(k).join('/')); + } catch (_) { + continue; // unreadable/odd file → skip + } + collected.add(parsed); + if (parsed.isRoot) break; + } + + // Apply farthest-first so the nearest file's sections win; within a file, + // later matching sections override earlier ones. + final props = {}; + for (final cfg in collected.reversed) { + for (final section in cfg.sections) { + if (_globMatches(section.glob, cfg.relForMatch)) { + props.addAll(section.props); + } + } + } + return EditorConfig.fromProps(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|$)'), ''); + } + + final eol = cfg.eolString; + if (eol != null) { + out = out.replaceAll(RegExp(r'\r\n|\r|\n'), eol); + } + + 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)+$'), ''); + } + return out; +} + +String? _detectEol(String s) { + final m = RegExp(r'\r\n|\r|\n').firstMatch(s); + return m?.group(0); +} + +// --------------------------------------------------------------------------- +// INI parsing +// --------------------------------------------------------------------------- + +class _ConfigFile { + _ConfigFile({required this.isRoot, required this.sections, required this.relForMatch}); + + final bool isRoot; + final List<_Section> sections; + + /// Path of the queried file relative to this config file's directory. + final String relForMatch; +} + +class _Section { + _Section(this.glob) : props = {}; + final String glob; + final Map props; +} + +_ConfigFile _parseIni(String text, String relForMatch) { + var isRoot = false; + final sections = <_Section>[]; + _Section? current; + + for (var line in text.split('\n')) { + line = line.trim(); + if (line.isEmpty || line.startsWith('#') || line.startsWith(';')) continue; + + if (line.startsWith('[') && line.endsWith(']') && line.length >= 2) { + current = _Section(line.substring(1, line.length - 1)); + sections.add(current); + continue; + } + + final eq = line.indexOf('='); + if (eq < 0) continue; + final key = line.substring(0, eq).trim().toLowerCase(); + final value = line.substring(eq + 1).trim(); + if (current == null) { + // Preamble: only `root` is meaningful at the top of the file. + if (key == 'root') isRoot = value.toLowerCase() == 'true'; + } else { + current.props[key] = value; + } + } + return _ConfigFile(isRoot: isRoot, sections: sections, relForMatch: relForMatch); +} + +// --------------------------------------------------------------------------- +// Glob matching (EditorConfig flavour) +// --------------------------------------------------------------------------- + +final Map _globCache = {}; + +/// Whether the EditorConfig section [glob] matches [path] (the file relative to +/// the config file's directory, `/`-separated). +bool _globMatches(String glob, String path) { + final re = _globCache.putIfAbsent(glob, () => RegExp('^${_globToRegex(glob)}\$')); + return re.hasMatch(path); +} + +/// Translate an EditorConfig glob to a regex body (unanchored). +/// +/// Supports `*` (any run of non-separators), `**` (any run, separators +/// included), `?` (one non-separator), `[seq]`/`[!seq]` character classes, +/// `{a,b,c}` alternation, and `{m..n}` numeric ranges. A glob with no `/` may +/// match in any subdirectory; one with a `/` is anchored to the config dir. +String _globToRegex(String glob) { + // A pattern containing no separator matches the file in any directory. + // One that does is anchored to the config file's directory; a leading + // slash is just that anchor and is dropped. + final hasSlash = glob.contains('/'); + var g = glob; + if (g.startsWith('/')) g = g.substring(1); + final prefix = hasSlash ? '' : '(?:.*/)?'; + return prefix + _translate(g); +} + +String _translate(String pat) { + final sb = StringBuffer(); + var i = 0; + final n = pat.length; + while (i < n) { + final c = pat[i]; + if (c == '*') { + if (i + 1 < n && pat[i + 1] == '*') { + // `**/` collapses the trailing slash so zero directories also match. + if (i + 2 < n && pat[i + 2] == '/') { + sb.write('(?:.*/)?'); + i += 3; + } else { + sb.write('.*'); + i += 2; + } + } else { + sb.write('[^/]*'); + i += 1; + } + } else if (c == '?') { + sb.write('[^/]'); + i += 1; + } else if (c == '[') { + i = _translateClass(pat, i, sb); + } else if (c == '{') { + i = _translateBrace(pat, i, sb); + } else if (c == '\\' && i + 1 < n) { + sb.write(RegExp.escape(pat[i + 1])); + i += 2; + } else { + sb.write(RegExp.escape(c)); + i += 1; + } + } + return sb.toString(); +} + +/// Translate a `[...]` character class starting at [start] (`pat[start] == '['`). +/// Returns the index just past the closing `]`. Falls back to a literal `[` +/// when the class is unterminated. +int _translateClass(String pat, int start, StringBuffer sb) { + final n = pat.length; + var j = start + 1; + var negate = false; + if (j < n && (pat[j] == '!' || pat[j] == '^')) { + negate = true; + j++; + } + final body = StringBuffer(); + var closed = false; + while (j < n) { + final c = pat[j]; + if (c == ']') { + closed = true; + break; + } + if (c == '\\' && j + 1 < n) { + body.write(RegExp.escape(pat[j + 1])); + j += 2; + continue; + } + // Inside a class only `\` and `]` are special to us; keep ranges (a-z) as is. + body.write(c == '^' || c == '[' ? '\\$c' : c); + j++; + } + if (!closed) { + sb.write(RegExp.escape('[')); + return start + 1; + } + sb.write('[${negate ? '^' : ''}${body.toString()}]'); + return j + 1; // past ']' +} + +/// Translate a `{...}` group starting at [start] (`pat[start] == '{'`). +/// Handles `{a,b,c}` alternation and `{m..n}` numeric ranges; an unterminated +/// or single-item brace is emitted literally. Returns the index past `}`. +int _translateBrace(String pat, int start, StringBuffer sb) { + final close = _matchingBrace(pat, start); + if (close < 0) { + sb.write(RegExp.escape('{')); + return start + 1; + } + final inner = pat.substring(start + 1, close); + + // Numeric range {m..n}. + final range = RegExp(r'^(-?\d+)\.\.(-?\d+)$').firstMatch(inner); + if (range != null) { + final lo = int.parse(range.group(1)!); + final hi = int.parse(range.group(2)!); + final from = lo <= hi ? lo : hi; + final to = lo <= hi ? hi : lo; + // Cap the expansion; huge ranges fall back to a generic integer match. + if (to - from <= 4096) { + final alts = [for (var k = from; k <= to; k++) '$k'].map(RegExp.escape).join('|'); + sb.write('(?:$alts)'); + } else { + sb.write(r'(?:-?\d+)'); + } + return close + 1; + } + + final parts = _splitTopLevel(inner); + if (parts.length <= 1) { + // Not a real alternation (`{` with no top-level comma) — literal braces. + sb.write(RegExp.escape('{')); + sb.write(_translate(inner)); + sb.write(RegExp.escape('}')); + return close + 1; + } + sb.write('(?:${parts.map(_translate).join('|')})'); + return close + 1; +} + +int _matchingBrace(String pat, int open) { + var depth = 0; + for (var j = open; j < pat.length; j++) { + final c = pat[j]; + if (c == '\\') { + j++; + continue; + } + if (c == '{') depth++; + if (c == '}') { + depth--; + if (depth == 0) return j; + } + } + return -1; +} + +/// Split [s] on commas that are not nested inside `{}` or `[]`. +List _splitTopLevel(String s) { + final out = []; + final buf = StringBuffer(); + var brace = 0; + var bracket = 0; + for (var j = 0; j < s.length; j++) { + final c = s[j]; + if (c == '\\' && j + 1 < s.length) { + buf.write(c); + buf.write(s[j + 1]); + j++; + continue; + } + if (c == '{') brace++; + if (c == '}') brace--; + if (c == '[') bracket++; + if (c == ']') bracket--; + if (c == ',' && brace == 0 && bracket == 0) { + out.add(buf.toString()); + buf.clear(); + } else { + buf.write(c); + } + } + out.add(buf.toString()); + return out; +} diff --git a/lib/src/editor/registry.dart b/lib/src/editor/registry.dart index f93fe37b..bdf6f58f 100644 --- a/lib/src/editor/registry.dart +++ b/lib/src/editor/registry.dart @@ -12,6 +12,7 @@ import 'dart:io'; import '../ipc/envelope.dart'; import '../panes/event_sink.dart'; import 'buffer.dart'; +import 'editorconfig.dart'; class EditorRegistry { EditorRegistry({ @@ -52,7 +53,12 @@ class EditorRegistry { } final id = 'b_${_nextId++}'; - final buf = EditorBuffer(id: id, path: path, content: content); + final buf = EditorBuffer( + id: id, + path: path, + content: content, + editorConfig: resolveEditorConfig(workspaceRoot, path), + ); _buffers[id] = buf; _pathToId[path] = id; @@ -144,12 +150,37 @@ class EditorRegistry { }); } - /// Persist [id] to disk. Clears the dirty flag on success. + /// 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. Future save(String id) async { final buf = _buffers[id]; if (buf == null) return false; + + final normalized = applyEditorConfigOnSave(buf.content, buf.editorConfig); + final changed = normalized != buf.content; + final absolute = _absolutePathOf(buf.path); - await File(absolute).writeAsString(buf.content); + await File(absolute).writeAsString(normalized); + + if (changed) { + buf.content = normalized; + buf.selection = Selection( + start: buf.selection.start.clamp(0, normalized.length), + end: buf.selection.end.clamp(0, normalized.length), + ); + // Re-broadcast so the UI reloads the normalized text (the editor.edited + // handler re-reads the buffer); emitted before editor.saved clears dirty. + buf.dirty = false; + _emit('editor.edited', { + 'id': id, + 'kind': 'replace', + 'length': normalized.length, + 'selection': buf.selection.toJson(), + }); + } + buf.dirty = false; _emit('editor.saved', {'id': id, 'path': buf.path}); return true; diff --git a/test/editor/editorconfig_test.dart b/test/editor/editorconfig_test.dart new file mode 100644 index 00000000..0826b966 --- /dev/null +++ b/test/editor/editorconfig_test.dart @@ -0,0 +1,250 @@ +import 'dart:io'; + +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({ + 'indent_style': 'space', + 'indent_size': '2', + 'end_of_line': 'lf', + 'trim_trailing_whitespace': 'true', + 'insert_final_newline': 'true', + 'max_line_length': '100', + }); + expect(c.indentStyle, 'space'); + expect(c.indentSize, 2); + expect(c.tabWidth, 2); // defaults to indent_size + expect(c.endOfLine, 'lf'); + expect(c.trimTrailingWhitespace, isTrue); + expect(c.insertFinalNewline, isTrue); + expect(c.maxLineLength, 100); + }); + + test('indent_size = tab follows tab_width', () { + final c = EditorConfig.fromProps({'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); + }); + + 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'}); + 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'}); + 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', () { + late Directory root; + setUp(() async => root = await Directory.systemTemp.createTemp('clide-ec-glob-')); + tearDown(() => root.deleteSync(recursive: true)); + + Future write(String relPath, String contents) async { + final f = File('${root.path}/$relPath'); + await f.parent.create(recursive: true); + await f.writeAsString(contents); + } + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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 + }); + }); + + group('resolveEditorConfig — precedence', () { + late Directory root; + setUp(() async => root = await Directory.systemTemp.createTemp('clide-ec-prec-')); + tearDown(() => root.deleteSync(recursive: true)); + + Future write(String relPath, String contents) async { + final f = File('${root.path}/$relPath'); + await f.parent.create(recursive: true); + await f.writeAsString(contents); + } + + 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 + }); + + test('root = true halts the ascent', () async { + await write('.editorconfig', '[*]\nindent_style = tab\n'); // would apply if reached + await write('sub/.editorconfig', 'root = true\n[*]\nindent_size = 4\n'); + final c = resolveEditorConfig(root, 'sub/x.dart'); + expect(c.indentSize, 4); + expect(c.indentStyle, isNull); // top-level file never consulted + }); + + 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); + }); + + 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); + }); + + test('no .editorconfig anywhere resolves to empty', () async { + expect(resolveEditorConfig(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); + }); + + 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); + }); + }); +} diff --git a/test/editor/registry_test.dart b/test/editor/registry_test.dart index 8be218a4..00a95734 100644 --- a/test/editor/registry_test.dart +++ b/test/editor/registry_test.dart @@ -151,4 +151,51 @@ void main() { expect(s.hashCode, isNot(equals(const Selection(start: 3, end: 8).hashCode))); expect(s.toString(), 'Selection(3-7)'); }); + + group('.editorconfig (T-29)', () { + test('open resolves the editorconfig 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); + // Exposed over IPC for the UI. + expect(buf.toJson()['editorConfig'], {'indent_style': 'space', 'indent_size': 2, 'tab_width': 2}); + }); + + test('save trims trailing whitespace + adds a final newline on disk', () async { + await File('${sandbox.path}/.editorconfig').writeAsString('root = true\n[*]\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n'); + final buf = await reg.open('README.md'); + reg.setContent(buf.id, 'line one \nline two'); + sink.events.clear(); + + await reg.save(buf.id); + + final onDisk = await File('${sandbox.path}/README.md').readAsString(); + expect(onDisk, 'line one\nline two\n'); + // The in-memory buffer reconciles to the normalized text... + expect(buf.content, 'line one\nline two\n'); + expect(buf.dirty, isFalse); + // ...and the UI is told to reload it. + expect(sink.ofKind('editor.edited'), hasLength(1)); + expect(sink.ofKind('editor.saved'), hasLength(1)); + }); + + test('save normalizes EOL to the configured style', () async { + await File('${sandbox.path}/.editorconfig').writeAsString('root = true\n[*]\nend_of_line = crlf\n'); + final buf = await reg.open('README.md'); + reg.setContent(buf.id, 'a\nb\n'); + await reg.save(buf.id); + expect(await File('${sandbox.path}/README.md').readAsString(), 'a\r\nb\r\n'); + }); + + test('save without an editorconfig writes content verbatim (no extra edit event)', () async { + final buf = await reg.open('README.md'); + reg.setContent(buf.id, 'kept \nas-is'); + sink.events.clear(); + await reg.save(buf.id); + expect(await File('${sandbox.path}/README.md').readAsString(), 'kept \nas-is'); + expect(sink.ofKind('editor.edited'), isEmpty); // nothing to reconcile + expect(sink.ofKind('editor.saved'), hasLength(1)); + }); + }); }