cov-loupe 3.0.0 → 4.0.0.pre
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.md +230 -0
- data/CLAUDE.md +5 -0
- data/CODE_OF_CONDUCT.md +62 -0
- data/CONTRIBUTING.md +102 -0
- data/GEMINI.md +5 -0
- data/README.md +154 -51
- data/RELEASE_NOTES.md +452 -0
- data/dev/images/cov-loupe-icon-lores.png +0 -0
- data/dev/images/cov-loupe-icon-square.png +0 -0
- data/dev/images/cov-loupe-icon.png +0 -0
- data/dev/images/cov-loupe-logo.png +0 -0
- data/dev/prompts/README.md +74 -0
- data/dev/prompts/archive/architectural-review-and-actions-prompt.md +53 -0
- data/dev/prompts/archive/investigate-and-report-issues-prompt.md +33 -0
- data/dev/prompts/archive/produce-action-items-prompt.md +25 -0
- data/dev/prompts/guidelines/ai-code-evaluator-guidelines.md +337 -0
- data/dev/prompts/improve/refactor-test-suite.md +18 -0
- data/dev/prompts/improve/simplify-code-logic.md +133 -0
- data/dev/prompts/improve/update-documentation.md +21 -0
- data/dev/prompts/review/comprehensive-codebase-review.md +176 -0
- data/dev/prompts/review/identify-action-items.md +143 -0
- data/dev/prompts/review/verify-code-changes.md +54 -0
- data/dev/prompts/validate/create-screencast-outline.md +234 -0
- data/dev/prompts/validate/test-documentation-examples.md +180 -0
- data/docs/QUICKSTART.md +63 -0
- data/docs/assets/images/cov-loupe-logo-lores.png +0 -0
- data/docs/assets/images/cov-loupe-logo.png +0 -0
- data/docs/assets/images/favicon.png +0 -0
- data/docs/assets/stylesheets/branding.css +16 -0
- data/docs/assets/stylesheets/extra.css +15 -0
- data/docs/code_of_conduct.md +1 -0
- data/docs/contributing.md +1 -0
- data/docs/dev/ARCHITECTURE.md +56 -11
- data/docs/dev/DEVELOPMENT.md +116 -12
- data/docs/dev/FUTURE_ENHANCEMENTS.md +14 -0
- data/docs/dev/README.md +3 -2
- data/docs/dev/RELEASING.md +2 -0
- data/docs/dev/arch-decisions/README.md +10 -7
- data/docs/dev/arch-decisions/application-architecture.md +259 -0
- data/docs/dev/arch-decisions/coverage-data-quality.md +193 -0
- data/docs/dev/arch-decisions/output-character-mode.md +217 -0
- data/docs/dev/arch-decisions/path-resolution.md +90 -0
- data/docs/dev/arch-decisions/{004-x-arch-decision.md → policy-validation.md} +32 -28
- data/docs/dev/arch-decisions/{005-x-arch-decision.md → simplecov-integration.md} +47 -44
- data/docs/dev/presentations/cov-loupe-presentation.md +15 -13
- data/docs/examples/mcp-inputs.md +3 -0
- data/docs/examples/prompts.md +3 -0
- data/docs/examples/success_predicates.md +3 -0
- data/docs/fixtures/demo_project/.resultset.json +170 -0
- data/docs/fixtures/demo_project/README.md +6 -0
- data/docs/fixtures/demo_project/app/controllers/admin/audit_logs_controller.rb +19 -0
- data/docs/fixtures/demo_project/app/controllers/orders_controller.rb +26 -0
- data/docs/fixtures/demo_project/app/models/order.rb +20 -0
- data/docs/fixtures/demo_project/app/models/user.rb +19 -0
- data/docs/fixtures/demo_project/lib/api/client.rb +22 -0
- data/docs/fixtures/demo_project/lib/ops/jobs/cleanup_job.rb +16 -0
- data/docs/fixtures/demo_project/lib/ops/jobs/report_job.rb +17 -0
- data/docs/fixtures/demo_project/lib/payments/processor.rb +15 -0
- data/docs/fixtures/demo_project/lib/payments/refund_service.rb +15 -0
- data/docs/fixtures/demo_project/lib/payments/reporting/exporter.rb +16 -0
- data/docs/index.md +1 -0
- data/docs/license.md +3 -0
- data/docs/release_notes.md +3 -0
- data/docs/user/ADVANCED_USAGE.md +208 -115
- data/docs/user/CLI_FALLBACK_FOR_LLMS.md +2 -0
- data/docs/user/CLI_USAGE.md +276 -101
- data/docs/user/ERROR_HANDLING.md +4 -4
- data/docs/user/EXAMPLES.md +121 -128
- data/docs/user/INSTALLATION.md +9 -28
- data/docs/user/LIBRARY_API.md +227 -122
- data/docs/user/MCP_INTEGRATION.md +114 -203
- data/docs/user/README.md +5 -1
- data/docs/user/TROUBLESHOOTING.md +49 -27
- data/docs/user/installing-a-prelease-version-of-covloupe.md +43 -0
- data/docs/user/{V2-BREAKING-CHANGES.md → migrations/MIGRATING_TO_V2.md} +62 -72
- data/docs/user/migrations/MIGRATING_TO_V3.md +72 -0
- data/docs/user/migrations/MIGRATING_TO_V4.md +591 -0
- data/docs/user/migrations/README.md +22 -0
- data/docs/user/prompts/README.md +9 -0
- data/docs/user/prompts/non-web-coverage-analysis-prompt.md +103 -0
- data/docs/user/prompts/rails-coverage-analysis-prompt.md +94 -0
- data/docs/user/prompts/use-cli-not-mcp-prompt.md +53 -0
- data/examples/cli_demo.sh +77 -0
- data/examples/filter_and_table_demo-output.md +114 -0
- data/examples/filter_and_table_demo.rb +174 -0
- data/examples/fixtures/demo_project/coverage/.resultset.json +10 -0
- data/examples/mcp-inputs/README.md +66 -0
- data/examples/mcp-inputs/coverage_detailed.json +1 -0
- data/examples/mcp-inputs/coverage_raw.json +1 -0
- data/examples/mcp-inputs/coverage_summary.json +1 -0
- data/examples/mcp-inputs/list.json +1 -0
- data/examples/mcp-inputs/uncovered_lines.json +1 -0
- data/examples/prompts/README.md +27 -0
- data/examples/prompts/custom_resultset.txt +2 -0
- data/examples/prompts/detailed_with_source.txt +2 -0
- data/examples/prompts/list_lowest.txt +2 -0
- data/examples/prompts/summary.txt +2 -0
- data/examples/prompts/uncovered.txt +2 -0
- data/examples/success_predicates/README.md +198 -0
- data/examples/success_predicates/all_files_above_threshold_predicate.rb +21 -0
- data/examples/success_predicates/directory_specific_thresholds_predicate.rb +30 -0
- data/examples/success_predicates/project_coverage_minimum_predicate.rb +6 -0
- data/lib/cov_loupe/base_tool.rb +229 -20
- data/lib/cov_loupe/cli.rb +132 -23
- data/lib/cov_loupe/commands/base_command.rb +25 -6
- data/lib/cov_loupe/commands/command_factory.rb +0 -1
- data/lib/cov_loupe/commands/detailed_command.rb +10 -5
- data/lib/cov_loupe/commands/list_command.rb +2 -1
- data/lib/cov_loupe/commands/raw_command.rb +7 -5
- data/lib/cov_loupe/commands/summary_command.rb +12 -7
- data/lib/cov_loupe/commands/totals_command.rb +74 -10
- data/lib/cov_loupe/commands/uncovered_command.rb +7 -5
- data/lib/cov_loupe/commands/validate_command.rb +11 -3
- data/lib/cov_loupe/commands/version_command.rb +6 -4
- data/lib/cov_loupe/{app_config.rb → config/app_config.rb} +13 -5
- data/lib/cov_loupe/config/app_context.rb +43 -0
- data/lib/cov_loupe/config/boolean_type.rb +91 -0
- data/lib/cov_loupe/config/logger.rb +92 -0
- data/lib/cov_loupe/{option_normalizers.rb → config/option_normalizers.rb} +55 -24
- data/lib/cov_loupe/{option_parser_builder.rb → config/option_parser_builder.rb} +46 -24
- data/lib/cov_loupe/coverage/coverage_calculator.rb +53 -0
- data/lib/cov_loupe/coverage/coverage_reporter.rb +63 -0
- data/lib/cov_loupe/coverage/coverage_table_formatter.rb +133 -0
- data/lib/cov_loupe/{error_handler.rb → errors/error_handler.rb} +21 -33
- data/lib/cov_loupe/{errors.rb → errors/errors.rb} +48 -71
- data/lib/cov_loupe/formatters/formatters.rb +75 -0
- data/lib/cov_loupe/formatters/source_formatter.rb +18 -7
- data/lib/cov_loupe/formatters/table_formatter.rb +80 -0
- data/lib/cov_loupe/loaders/all.rb +15 -0
- data/lib/cov_loupe/loaders/all_cli.rb +10 -0
- data/lib/cov_loupe/loaders/all_mcp.rb +23 -0
- data/lib/cov_loupe/loaders/resultset_loader.rb +147 -0
- data/lib/cov_loupe/mcp_server.rb +3 -2
- data/lib/cov_loupe/model/model.rb +520 -0
- data/lib/cov_loupe/model/model_data.rb +13 -0
- data/lib/cov_loupe/model/model_data_cache.rb +116 -0
- data/lib/cov_loupe/option_parsers/env_options_parser.rb +17 -6
- data/lib/cov_loupe/option_parsers/error_helper.rb +16 -10
- data/lib/cov_loupe/output_chars.rb +192 -0
- data/lib/cov_loupe/paths/glob_utils.rb +100 -0
- data/lib/cov_loupe/{path_relativizer.rb → paths/path_relativizer.rb} +5 -13
- data/lib/cov_loupe/paths/path_utils.rb +265 -0
- data/lib/cov_loupe/paths/volume_case_sensitivity.rb +173 -0
- data/lib/cov_loupe/presenters/base_coverage_presenter.rb +9 -13
- data/lib/cov_loupe/presenters/coverage_payload_presenter.rb +21 -0
- data/lib/cov_loupe/presenters/payload_caching.rb +23 -0
- data/lib/cov_loupe/presenters/project_coverage_presenter.rb +73 -21
- data/lib/cov_loupe/presenters/project_totals_presenter.rb +16 -10
- data/lib/cov_loupe/repositories/coverage_repository.rb +149 -0
- data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +90 -76
- data/lib/cov_loupe/resolvers/{resolver_factory.rb → resolver_helpers.rb} +6 -5
- data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +40 -12
- data/lib/cov_loupe/scripts/command_execution.rb +113 -0
- data/lib/cov_loupe/scripts/latest_ci_status.rb +97 -0
- data/lib/cov_loupe/scripts/pre_release_check.rb +164 -0
- data/lib/cov_loupe/scripts/setup_doc_server.rb +23 -0
- data/lib/cov_loupe/scripts/start_doc_server.rb +24 -0
- data/lib/cov_loupe/staleness/stale_status.rb +23 -0
- data/lib/cov_loupe/staleness/staleness_checker.rb +328 -0
- data/lib/cov_loupe/staleness/staleness_message_formatter.rb +91 -0
- data/lib/cov_loupe/tools/coverage_detailed_tool.rb +14 -15
- data/lib/cov_loupe/tools/coverage_raw_tool.rb +14 -14
- data/lib/cov_loupe/tools/coverage_summary_tool.rb +16 -16
- data/lib/cov_loupe/tools/coverage_table_tool.rb +139 -21
- data/lib/cov_loupe/tools/coverage_totals_tool.rb +31 -13
- data/lib/cov_loupe/tools/help_tool.rb +16 -20
- data/lib/cov_loupe/tools/list_tool.rb +65 -0
- data/lib/cov_loupe/tools/uncovered_lines_tool.rb +14 -14
- data/lib/cov_loupe/tools/validate_tool.rb +18 -24
- data/lib/cov_loupe/tools/version_tool.rb +8 -3
- data/lib/cov_loupe/version.rb +1 -1
- data/lib/cov_loupe.rb +83 -55
- metadata +184 -154
- data/docs/dev/BRANCH_ONLY_COVERAGE.md +0 -158
- data/docs/dev/arch-decisions/001-x-arch-decision.md +0 -95
- data/docs/dev/arch-decisions/002-x-arch-decision.md +0 -159
- data/docs/dev/arch-decisions/003-x-arch-decision.md +0 -165
- data/lib/cov_loupe/app_context.rb +0 -26
- data/lib/cov_loupe/constants.rb +0 -22
- data/lib/cov_loupe/coverage_reporter.rb +0 -31
- data/lib/cov_loupe/formatters.rb +0 -51
- data/lib/cov_loupe/mode_detector.rb +0 -56
- data/lib/cov_loupe/model.rb +0 -339
- data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +0 -14
- data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +0 -14
- data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +0 -14
- data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +0 -14
- data/lib/cov_loupe/resultset_loader.rb +0 -131
- data/lib/cov_loupe/staleness_checker.rb +0 -247
- data/lib/cov_loupe/table_formatter.rb +0 -64
- data/lib/cov_loupe/tools/all_files_coverage_tool.rb +0 -51
- data/lib/cov_loupe/util.rb +0 -88
- data/spec/MCP_INTEGRATION_TESTS_README.md +0 -111
- data/spec/TIMESTAMPS.md +0 -48
- data/spec/all_files_coverage_tool_spec.rb +0 -53
- data/spec/app_config_spec.rb +0 -142
- data/spec/base_tool_spec.rb +0 -62
- data/spec/cli/show_default_report_spec.rb +0 -33
- data/spec/cli_enumerated_options_spec.rb +0 -90
- data/spec/cli_error_spec.rb +0 -184
- data/spec/cli_format_spec.rb +0 -123
- data/spec/cli_json_options_spec.rb +0 -50
- data/spec/cli_source_spec.rb +0 -44
- data/spec/cli_spec.rb +0 -192
- data/spec/cli_table_spec.rb +0 -28
- data/spec/cli_usage_spec.rb +0 -42
- data/spec/commands/base_command_spec.rb +0 -107
- data/spec/commands/command_factory_spec.rb +0 -76
- data/spec/commands/detailed_command_spec.rb +0 -34
- data/spec/commands/list_command_spec.rb +0 -28
- data/spec/commands/raw_command_spec.rb +0 -69
- data/spec/commands/summary_command_spec.rb +0 -34
- data/spec/commands/totals_command_spec.rb +0 -34
- data/spec/commands/uncovered_command_spec.rb +0 -55
- data/spec/commands/validate_command_spec.rb +0 -213
- data/spec/commands/version_command_spec.rb +0 -38
- data/spec/constants_spec.rb +0 -61
- data/spec/cov_loupe/formatters/source_formatter_spec.rb +0 -267
- data/spec/cov_loupe/formatters_spec.rb +0 -76
- data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +0 -79
- data/spec/cov_loupe_model_spec.rb +0 -454
- data/spec/cov_loupe_module_spec.rb +0 -37
- data/spec/cov_loupe_opts_spec.rb +0 -185
- data/spec/coverage_reporter_spec.rb +0 -102
- data/spec/coverage_table_tool_spec.rb +0 -59
- data/spec/coverage_totals_tool_spec.rb +0 -37
- data/spec/error_handler_spec.rb +0 -197
- data/spec/error_mode_spec.rb +0 -139
- data/spec/errors_edge_cases_spec.rb +0 -312
- data/spec/errors_stale_spec.rb +0 -83
- data/spec/file_based_mcp_tools_spec.rb +0 -99
- data/spec/help_tool_spec.rb +0 -26
- data/spec/integration_spec.rb +0 -789
- data/spec/logging_fallback_spec.rb +0 -128
- data/spec/mcp_logging_spec.rb +0 -44
- data/spec/mcp_server_integration_spec.rb +0 -23
- data/spec/mcp_server_spec.rb +0 -106
- data/spec/mode_detector_spec.rb +0 -153
- data/spec/model_error_handling_spec.rb +0 -269
- data/spec/model_staleness_spec.rb +0 -79
- data/spec/option_normalizers_spec.rb +0 -203
- data/spec/option_parsers/env_options_parser_spec.rb +0 -221
- data/spec/option_parsers/error_helper_spec.rb +0 -222
- data/spec/path_relativizer_spec.rb +0 -98
- data/spec/presenters/coverage_detailed_presenter_spec.rb +0 -19
- data/spec/presenters/coverage_raw_presenter_spec.rb +0 -15
- data/spec/presenters/coverage_summary_presenter_spec.rb +0 -15
- data/spec/presenters/coverage_uncovered_presenter_spec.rb +0 -16
- data/spec/presenters/project_coverage_presenter_spec.rb +0 -87
- data/spec/presenters/project_totals_presenter_spec.rb +0 -144
- data/spec/resolvers/coverage_line_resolver_spec.rb +0 -282
- data/spec/resolvers/resolver_factory_spec.rb +0 -61
- data/spec/resolvers/resultset_path_resolver_spec.rb +0 -60
- data/spec/resultset_loader_spec.rb +0 -167
- data/spec/shared_examples/README.md +0 -115
- data/spec/shared_examples/coverage_presenter_examples.rb +0 -66
- data/spec/shared_examples/file_based_mcp_tools.rb +0 -179
- data/spec/shared_examples/formatted_command_examples.rb +0 -64
- data/spec/shared_examples/mcp_tool_text_json_response.rb +0 -16
- data/spec/spec_helper.rb +0 -127
- data/spec/staleness_checker_spec.rb +0 -374
- data/spec/staleness_more_spec.rb +0 -42
- data/spec/support/cli_helpers.rb +0 -22
- data/spec/support/control_flow_helpers.rb +0 -20
- data/spec/support/fake_mcp.rb +0 -40
- data/spec/support/io_helpers.rb +0 -29
- data/spec/support/mcp_helpers.rb +0 -35
- data/spec/support/mcp_runner.rb +0 -66
- data/spec/support/mocking_helpers.rb +0 -30
- data/spec/table_format_spec.rb +0 -70
- data/spec/tools/validate_tool_spec.rb +0 -132
- data/spec/tools_error_handling_spec.rb +0 -130
- data/spec/util_spec.rb +0 -154
- data/spec/version_spec.rb +0 -123
- data/spec/version_tool_spec.rb +0 -141
- /data/{spec/fixtures/project1 → examples/fixtures/demo_project}/lib/bar.rb +0 -0
- /data/{spec/fixtures/project1 → examples/fixtures/demo_project}/lib/foo.rb +0 -0
- /data/lib/cov_loupe/{config_parser.rb → config/config_parser.rb} +0 -0
- /data/lib/cov_loupe/{predicate_evaluator.rb → config/predicate_evaluator.rb} +0 -0
- /data/lib/cov_loupe/{error_handler_factory.rb → errors/error_handler_factory.rb} +0 -0
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../output_chars'
|
|
4
|
+
|
|
3
5
|
module CovLoupe
|
|
4
6
|
module OptionParsers
|
|
5
7
|
class ErrorHelper
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def initialize(subcommands = SUBCOMMANDS)
|
|
8
|
+
def initialize(subcommands)
|
|
9
9
|
@subcommands = subcommands
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def handle_option_parser_error(error, argv: [],
|
|
13
|
-
|
|
12
|
+
def handle_option_parser_error(error, argv: [], output_chars: :default,
|
|
13
|
+
usage_hint: "Run '#{program_name} --help' for usage information.")
|
|
14
|
+
message = convert_text(error.message.to_s, output_chars)
|
|
14
15
|
# Suggest a subcommand when an invalid option matches a known subcommand
|
|
15
16
|
option = extract_invalid_option(message)
|
|
16
17
|
|
|
17
18
|
if option&.start_with?('--') && @subcommands.include?(option[2..])
|
|
18
|
-
suggest_subcommand(option)
|
|
19
|
+
suggest_subcommand(option, output_chars)
|
|
19
20
|
else
|
|
20
21
|
# Generic message from OptionParser
|
|
21
22
|
warn "Error: #{message}"
|
|
@@ -34,10 +35,16 @@ module CovLoupe
|
|
|
34
35
|
nil
|
|
35
36
|
end
|
|
36
37
|
|
|
37
|
-
private def suggest_subcommand(option)
|
|
38
|
+
private def suggest_subcommand(option, output_chars)
|
|
38
39
|
subcommand = option[2..]
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
msg1 = convert_text("Error: '#{option}' is not a valid option. Did you mean the '#{subcommand}' subcommand?", output_chars)
|
|
41
|
+
msg2 = convert_text("Try: #{program_name} #{subcommand} [args]", output_chars)
|
|
42
|
+
warn msg1
|
|
43
|
+
warn msg2
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private def convert_text(text, output_chars)
|
|
47
|
+
OutputChars.convert(text, output_chars)
|
|
41
48
|
end
|
|
42
49
|
|
|
43
50
|
private def build_enum_value_hint(argv)
|
|
@@ -92,7 +99,6 @@ module CovLoupe
|
|
|
92
99
|
|
|
93
100
|
private def enumerated_option_rules
|
|
94
101
|
[
|
|
95
|
-
{ switches: ['-S', '--staleness'], values: %w[off o error e], display: 'o[ff]|e[rror]' },
|
|
96
102
|
{ switches: ['-s', '--source'], values: %w[full f uncovered u],
|
|
97
103
|
display: 'f[ull]|u[ncovered]' },
|
|
98
104
|
{ switches: ['--error-mode'], values: %w[off o log l debug d],
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CovLoupe
|
|
4
|
+
# Central module for controlling ASCII vs Unicode (fancy) output.
|
|
5
|
+
#
|
|
6
|
+
# This module provides:
|
|
7
|
+
# - Mode resolution (:default -> :fancy or :ascii based on output encoding)
|
|
8
|
+
# - Character sets for table borders (Unicode box-drawing vs ASCII)
|
|
9
|
+
# - Text conversion for ensuring ASCII-only output when needed
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# mode = OutputChars.resolve_mode(:default) # => :fancy or :ascii
|
|
13
|
+
# charset = OutputChars.charset_for(mode) # => hash of border chars
|
|
14
|
+
# text = OutputChars.convert("café", mode) # => "caf?" in :ascii mode
|
|
15
|
+
module OutputChars
|
|
16
|
+
# Valid output character modes
|
|
17
|
+
MODES = %i[default fancy ascii].freeze
|
|
18
|
+
|
|
19
|
+
# Unicode box-drawing characters (fancy mode)
|
|
20
|
+
UNICODE_CHARSET = {
|
|
21
|
+
top_left: "\u250C",
|
|
22
|
+
top_right: "\u2510",
|
|
23
|
+
bottom_left: "\u2514",
|
|
24
|
+
bottom_right: "\u2518",
|
|
25
|
+
horizontal: "\u2500",
|
|
26
|
+
vertical: "\u2502",
|
|
27
|
+
top_tee: "\u252C",
|
|
28
|
+
bottom_tee: "\u2534",
|
|
29
|
+
left_tee: "\u251C",
|
|
30
|
+
right_tee: "\u2524",
|
|
31
|
+
cross: "\u253C"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# ASCII characters for table borders (ascii mode)
|
|
35
|
+
ASCII_CHARSET = {
|
|
36
|
+
top_left: '+',
|
|
37
|
+
top_right: '+',
|
|
38
|
+
bottom_left: '+',
|
|
39
|
+
bottom_right: '+',
|
|
40
|
+
horizontal: '-',
|
|
41
|
+
vertical: '|',
|
|
42
|
+
top_tee: '+',
|
|
43
|
+
bottom_tee: '+',
|
|
44
|
+
left_tee: '+',
|
|
45
|
+
right_tee: '+',
|
|
46
|
+
cross: '+'
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
# Resolves :default mode to :fancy or :ascii based on output encoding.
|
|
51
|
+
#
|
|
52
|
+
# @param mode [Symbol] One of :default, :fancy, or :ascii
|
|
53
|
+
# @param io [IO] The output stream to check encoding for (default: $stdout)
|
|
54
|
+
# @return [Symbol] :fancy or :ascii
|
|
55
|
+
def resolve_mode(mode, io: $stdout)
|
|
56
|
+
case mode
|
|
57
|
+
when :fancy then :fancy
|
|
58
|
+
when :ascii then :ascii
|
|
59
|
+
when :default then default_mode_for(io)
|
|
60
|
+
else
|
|
61
|
+
raise ArgumentError, "Invalid output_chars mode: #{mode.inspect}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns the character set hash for the given mode.
|
|
66
|
+
#
|
|
67
|
+
# @param mode [Symbol] :fancy or :ascii (use resolve_mode first if :default)
|
|
68
|
+
# @return [Hash] Character set with keys like :top_left, :horizontal, etc.
|
|
69
|
+
def charset_for(mode)
|
|
70
|
+
case mode
|
|
71
|
+
when :fancy then UNICODE_CHARSET
|
|
72
|
+
when :ascii then ASCII_CHARSET
|
|
73
|
+
when :default then charset_for(resolve_mode(:default))
|
|
74
|
+
else
|
|
75
|
+
raise ArgumentError, "Invalid output_chars mode: #{mode.inspect}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Converts text to ASCII-only when in :ascii mode.
|
|
80
|
+
# In :fancy mode, returns text unchanged.
|
|
81
|
+
#
|
|
82
|
+
# @param text [String] The text to convert
|
|
83
|
+
# @param mode [Symbol] :fancy, :ascii, or :default
|
|
84
|
+
# @param io [IO] The output stream for resolving :default (default: $stdout)
|
|
85
|
+
# @return [String] Original text (:fancy) or ASCII-only text (:ascii)
|
|
86
|
+
def convert(text, mode, io: $stdout)
|
|
87
|
+
return text if text.nil?
|
|
88
|
+
|
|
89
|
+
resolved = mode == :default ? resolve_mode(mode, io: io) : mode
|
|
90
|
+
return text if resolved == :fancy
|
|
91
|
+
|
|
92
|
+
to_ascii(text)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Checks if output should be ASCII-only for the given mode.
|
|
96
|
+
#
|
|
97
|
+
# @param mode [Symbol] :fancy, :ascii, or :default
|
|
98
|
+
# @param io [IO] The output stream for resolving :default (default: $stdout)
|
|
99
|
+
# @return [Boolean] true if ASCII-only output is required
|
|
100
|
+
def ascii_mode?(mode, io: $stdout)
|
|
101
|
+
resolved = mode == :default ? resolve_mode(mode, io: io) : mode
|
|
102
|
+
resolved == :ascii
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private def default_mode_for(io)
|
|
106
|
+
encoding = io.respond_to?(:external_encoding) ? io.external_encoding : nil
|
|
107
|
+
encoding ||= Encoding.default_external
|
|
108
|
+
|
|
109
|
+
utf8_compatible?(encoding) ? :fancy : :ascii
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Checks if the encoding is UTF-8 compatible.
|
|
113
|
+
#
|
|
114
|
+
# @param encoding [Encoding, nil] The encoding to check
|
|
115
|
+
# @return [Boolean] true if UTF-8 compatible
|
|
116
|
+
private def utf8_compatible?(encoding)
|
|
117
|
+
return false if encoding.nil?
|
|
118
|
+
|
|
119
|
+
encoding_name = encoding.name.upcase
|
|
120
|
+
encoding_name.include?('UTF-8') || encoding_name == 'UTF8'
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Converts a string to ASCII-only, replacing non-ASCII characters.
|
|
124
|
+
#
|
|
125
|
+
# Uses transliteration for common characters where sensible,
|
|
126
|
+
# falls back to '?' for others. This is a best-effort conversion
|
|
127
|
+
# that prioritizes readability over exactness.
|
|
128
|
+
#
|
|
129
|
+
# @param text [String] The text to convert
|
|
130
|
+
# @return [String] ASCII-only text
|
|
131
|
+
private def to_ascii(text)
|
|
132
|
+
text.each_char.map do |char|
|
|
133
|
+
if char.ord < 128
|
|
134
|
+
char
|
|
135
|
+
else
|
|
136
|
+
transliterate(char)
|
|
137
|
+
end
|
|
138
|
+
end.join
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Transliterates a single non-ASCII character to ASCII.
|
|
142
|
+
#
|
|
143
|
+
# @param char [String] Single character to transliterate
|
|
144
|
+
# @return [String] ASCII replacement (may be multiple characters)
|
|
145
|
+
private def transliterate(char)
|
|
146
|
+
# Common transliterations for readability
|
|
147
|
+
TRANSLITERATIONS[char] || '?'
|
|
148
|
+
end
|
|
149
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Common character transliterations to ASCII.
|
|
153
|
+
# This covers common accented characters and symbols users might encounter.
|
|
154
|
+
# Box-drawing characters are not included here; they're handled by charset_for.
|
|
155
|
+
TRANSLITERATIONS = {
|
|
156
|
+
# Accented vowels
|
|
157
|
+
'á' => 'a', 'à' => 'a', 'â' => 'a', 'ä' => 'a', 'ã' => 'a', 'å' => 'a',
|
|
158
|
+
'Á' => 'A', 'À' => 'A', 'Â' => 'A', 'Ä' => 'A', 'Ã' => 'A', 'Å' => 'A',
|
|
159
|
+
'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
|
|
160
|
+
'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E',
|
|
161
|
+
'í' => 'i', 'ì' => 'i', 'î' => 'i', 'ï' => 'i',
|
|
162
|
+
'Í' => 'I', 'Ì' => 'I', 'Î' => 'I', 'Ï' => 'I',
|
|
163
|
+
'ó' => 'o', 'ò' => 'o', 'ô' => 'o', 'ö' => 'o', 'õ' => 'o', 'ø' => 'o',
|
|
164
|
+
'Ó' => 'O', 'Ò' => 'O', 'Ô' => 'O', 'Ö' => 'O', 'Õ' => 'O', 'Ø' => 'O',
|
|
165
|
+
'ú' => 'u', 'ù' => 'u', 'û' => 'u', 'ü' => 'u',
|
|
166
|
+
'Ú' => 'U', 'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U',
|
|
167
|
+
# Other common characters
|
|
168
|
+
'ñ' => 'n', 'Ñ' => 'N',
|
|
169
|
+
'ç' => 'c', 'Ç' => 'C',
|
|
170
|
+
'ß' => 'ss',
|
|
171
|
+
'æ' => 'ae', 'Æ' => 'AE',
|
|
172
|
+
'œ' => 'oe', 'Œ' => 'OE',
|
|
173
|
+
# Common symbols
|
|
174
|
+
"\u20AC" => 'EUR', "\u00A3" => 'GBP', "\u00A5" => 'JPY',
|
|
175
|
+
"\u00A9" => '(c)', "\u00AE" => '(R)', "\u2122" => '(TM)',
|
|
176
|
+
"\u00B0" => 'deg',
|
|
177
|
+
"\u2026" => '...',
|
|
178
|
+
"\u2018" => "'", "\u2019" => "'", "\u201C" => '"', "\u201D" => '"',
|
|
179
|
+
"\u2013" => '-', "\u2014" => '--',
|
|
180
|
+
"\u00D7" => 'x', "\u00F7" => '/',
|
|
181
|
+
"\u2264" => '<=', "\u2265" => '>=', "\u2260" => '!=',
|
|
182
|
+
"\u2192" => '->', "\u2190" => '<-', "\u2194" => '<->',
|
|
183
|
+
"\u2713" => '[x]', "\u2717" => '[ ]', "\u2714" => '[x]', "\u2718" => '[ ]',
|
|
184
|
+
# Bullets and list markers
|
|
185
|
+
"\u2022" => '*', "\u25E6" => 'o', "\u25AA" => '-', "\u25B8" => '>',
|
|
186
|
+
# Box-drawing (for any stray usage outside tables)
|
|
187
|
+
"\u250C" => '+', "\u2510" => '+', "\u2514" => '+', "\u2518" => '+',
|
|
188
|
+
"\u2500" => '-', "\u2502" => '|',
|
|
189
|
+
"\u252C" => '+', "\u2534" => '+', "\u251C" => '+', "\u2524" => '+', "\u253C" => '+'
|
|
190
|
+
}.freeze
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'path_utils'
|
|
4
|
+
|
|
5
|
+
module CovLoupe
|
|
6
|
+
module GlobUtils
|
|
7
|
+
GLOB_MATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
8
|
+
|
|
9
|
+
# Returns a lambda that normalizes path separators for the current platform.
|
|
10
|
+
# On Windows, returns a lambda that converts backslashes to forward slashes.
|
|
11
|
+
# On Unix, returns a pass-through lambda.
|
|
12
|
+
# The lambda is memoized so platform detection only happens once.
|
|
13
|
+
# @return [Proc] lambda that takes a string and returns it normalized
|
|
14
|
+
module_function def fn_normalize_path_separators
|
|
15
|
+
@fn_normalize_path_separators ||= if CovLoupe.windows?
|
|
16
|
+
->(str) { str.tr('\\', '/') }
|
|
17
|
+
else
|
|
18
|
+
->(str) { str }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module_function def normalize_patterns(globs)
|
|
23
|
+
Array(globs).compact.map(&:to_s).reject(&:empty?)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Converts a pattern to absolute path relative to a root.
|
|
27
|
+
# Handles both relative patterns ("lib/*.rb") and absolute ones ("/tmp/*.rb").
|
|
28
|
+
#
|
|
29
|
+
# @param pattern [String] glob pattern
|
|
30
|
+
# @param root [String] root directory path
|
|
31
|
+
# @return [String] absolute pattern
|
|
32
|
+
module_function def absolutize_pattern(pattern, root)
|
|
33
|
+
File.expand_path(pattern, root)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Tests if a file path matches any of the given absolute glob patterns.
|
|
37
|
+
# Uses File.fnmatch? for pure string matching without filesystem access.
|
|
38
|
+
# Normalizes paths to forward slashes on Windows for cross-platform compatibility.
|
|
39
|
+
# Automatically handles case-insensitive filesystems by detecting volume case-sensitivity.
|
|
40
|
+
#
|
|
41
|
+
# @param abs_path [String] absolute file path to test
|
|
42
|
+
# @param patterns [Array<String>] absolute glob patterns
|
|
43
|
+
# @return [Boolean] true if the path matches at least one pattern
|
|
44
|
+
module_function def matches_any_pattern?(abs_path, patterns)
|
|
45
|
+
normalizer = fn_normalize_path_separators
|
|
46
|
+
normalized_path = normalizer.call(abs_path)
|
|
47
|
+
|
|
48
|
+
# Determine match flags based on volume case-sensitivity
|
|
49
|
+
# Find first existing parent directory to test volume properties
|
|
50
|
+
test_dir = abs_path
|
|
51
|
+
until File.directory?(test_dir)
|
|
52
|
+
parent = File.dirname(test_dir)
|
|
53
|
+
break if parent == test_dir # Reached root (works on Windows and Unix)
|
|
54
|
+
|
|
55
|
+
test_dir = parent
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
flags = GLOB_MATCH_FLAGS
|
|
59
|
+
begin
|
|
60
|
+
# Add case-insensitive matching for case-insensitive volumes
|
|
61
|
+
flags |= File::FNM_CASEFOLD unless PathUtils.volume_case_sensitive?(test_dir)
|
|
62
|
+
rescue SystemCallError, IOError
|
|
63
|
+
# If we can't detect case sensitivity, assume case-insensitive to be conservative
|
|
64
|
+
flags |= File::FNM_CASEFOLD
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
patterns.any? do |pattern|
|
|
68
|
+
normalized_pattern = normalizer.call(pattern)
|
|
69
|
+
File.fnmatch?(normalized_pattern, normalized_path, flags)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Filters items where a key contains a file path matching the patterns.
|
|
74
|
+
#
|
|
75
|
+
# @param items [Array<Hash>] items to filter
|
|
76
|
+
# @param patterns [Array<String>] absolute glob patterns
|
|
77
|
+
# @param key [String] key in item hash containing the absolute file path
|
|
78
|
+
# @return [Array<Hash>] items whose file path matches at least one pattern
|
|
79
|
+
module_function def filter_by_pattern(items, patterns, key: 'file')
|
|
80
|
+
return items if patterns.nil? || patterns.empty?
|
|
81
|
+
|
|
82
|
+
items.select { |item| matches_any_pattern?(item[key], patterns) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Filters an array of absolute file paths by glob patterns.
|
|
86
|
+
# Handles normalization and absolutization of patterns internally.
|
|
87
|
+
#
|
|
88
|
+
# @param paths [Array<String>] absolute file paths to filter
|
|
89
|
+
# @param globs [Array<String>, String, nil] glob patterns (can be relative)
|
|
90
|
+
# @param root [String] root directory for resolving relative patterns
|
|
91
|
+
# @return [Array<String>] paths that match at least one pattern (or all if no patterns)
|
|
92
|
+
module_function def filter_paths(paths, globs, root:)
|
|
93
|
+
patterns = normalize_patterns(globs)
|
|
94
|
+
return paths if patterns.empty?
|
|
95
|
+
|
|
96
|
+
absolute_patterns = patterns.map { |p| absolutize_pattern(p, root) }
|
|
97
|
+
paths.select { |path| matches_any_pattern?(path, absolute_patterns) }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'pathname'
|
|
4
|
+
require_relative 'path_utils'
|
|
4
5
|
|
|
5
6
|
module CovLoupe
|
|
6
7
|
# Utility object that converts configured path-bearing keys to forms
|
|
7
|
-
# relative to
|
|
8
|
+
# relative to a project root while leaving the original payload untouched.
|
|
8
9
|
class PathRelativizer
|
|
9
10
|
def initialize(root:, scalar_keys:, array_keys: [])
|
|
10
|
-
@root = Pathname.new(
|
|
11
|
+
@root = Pathname.new(PathUtils.expand(root || '.'))
|
|
11
12
|
@scalar_keys = Array(scalar_keys).map(&:to_s).freeze
|
|
12
13
|
@array_keys = Array(array_keys).map(&:to_s).freeze
|
|
13
14
|
end
|
|
@@ -22,13 +23,8 @@ module CovLoupe
|
|
|
22
23
|
# @param path [String] file path (absolute or relative)
|
|
23
24
|
# @return [String] relative path or original path on failure
|
|
24
25
|
def relativize_path(path)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return path unless abs.start_with?(root_prefix(root_str)) || abs == root_str
|
|
28
|
-
|
|
29
|
-
Pathname.new(abs).relative_path_from(@root).to_s
|
|
30
|
-
rescue ArgumentError
|
|
31
|
-
path
|
|
26
|
+
# PathUtils handles all the complexity automatically
|
|
27
|
+
PathUtils.relativize(path, @root.to_s)
|
|
32
28
|
end
|
|
33
29
|
|
|
34
30
|
private def deep_copy_and_relativize(obj)
|
|
@@ -56,9 +52,5 @@ module CovLoupe
|
|
|
56
52
|
deep_copy_and_relativize(value)
|
|
57
53
|
end
|
|
58
54
|
end
|
|
59
|
-
|
|
60
|
-
private def root_prefix(root_str)
|
|
61
|
-
root_str.end_with?(File::SEPARATOR) ? root_str : root_str + File::SEPARATOR
|
|
62
|
-
end
|
|
63
55
|
end
|
|
64
56
|
end
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative 'volume_case_sensitivity'
|
|
5
|
+
|
|
6
|
+
module CovLoupe
|
|
7
|
+
# Centralized path handling utilities providing consistent normalization,
|
|
8
|
+
# relativization, and absolutization across all components.
|
|
9
|
+
module PathUtils
|
|
10
|
+
# Platform detection - delegates to main CovLoupe module for testability
|
|
11
|
+
def self.windows?
|
|
12
|
+
CovLoupe.windows?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.windows_drive?
|
|
16
|
+
File.expand_path('.').match?(/^[A-Za-z]:/)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Normalizes a path by handling:
|
|
20
|
+
# 1. Slash normalization (Windows backslashes -> forward slashes)
|
|
21
|
+
# 2. Case normalization (case-insensitive volumes)
|
|
22
|
+
# 3. Path cleaning (removing ., .., redundant separators)
|
|
23
|
+
#
|
|
24
|
+
# @param path [String, Pathname] path to normalize
|
|
25
|
+
# @param options [Hash] normalization options
|
|
26
|
+
# @option options [Boolean] :normalize_case (true on case-insensitive volumes)
|
|
27
|
+
# @option options [String] :root (nil) root directory for determining volume case-sensitivity
|
|
28
|
+
# @return [String] normalized path
|
|
29
|
+
def self.normalize(path, options = {})
|
|
30
|
+
return path if path.nil? || path.empty?
|
|
31
|
+
|
|
32
|
+
result = path.to_s
|
|
33
|
+
|
|
34
|
+
# Always normalize slashes on Windows (Pathname#cleanpath does this anyway)
|
|
35
|
+
result = result.tr('\\', '/') if windows?
|
|
36
|
+
|
|
37
|
+
# Handle case normalization for case-insensitive volumes
|
|
38
|
+
# If root is provided, derive case-sensitivity from root's volume
|
|
39
|
+
root = options[:root]
|
|
40
|
+
begin
|
|
41
|
+
default_normalize_case = if root
|
|
42
|
+
!VolumeCaseSensitivity.volume_case_sensitive?(root)
|
|
43
|
+
else
|
|
44
|
+
!VolumeCaseSensitivity.volume_case_sensitive?
|
|
45
|
+
end
|
|
46
|
+
rescue SystemCallError, IOError
|
|
47
|
+
# If we can't detect case sensitivity, assume case-insensitive to be conservative
|
|
48
|
+
default_normalize_case = true
|
|
49
|
+
end
|
|
50
|
+
if options.fetch(:normalize_case, default_normalize_case)
|
|
51
|
+
result = result.downcase
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Clean path components
|
|
55
|
+
Pathname.new(result).cleanpath.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Expands a path to absolute form, optionally relative to a base directory
|
|
59
|
+
#
|
|
60
|
+
# @param path [String] path to expand
|
|
61
|
+
# @param base [String, nil] base directory (defaults to current working directory)
|
|
62
|
+
# @return [String] absolute path
|
|
63
|
+
def self.expand(path, base = nil)
|
|
64
|
+
return path if path.nil? || path.empty?
|
|
65
|
+
|
|
66
|
+
# On Windows, only bypass File.expand_path if path already has a drive letter.
|
|
67
|
+
# Paths like "/foo" are considered absolute by absolute? but need File.expand_path
|
|
68
|
+
# to acquire the current drive letter (e.g., "C:/foo").
|
|
69
|
+
if absolute?(path) && (!windows? || path.match?(/^[A-Za-z]:/))
|
|
70
|
+
# Use Pathname#cleanpath to preserve case on Windows, as File.expand_path
|
|
71
|
+
# can sometimes canonicalize case for existing files.
|
|
72
|
+
Pathname.new(path).cleanpath.to_s
|
|
73
|
+
else
|
|
74
|
+
base ? File.expand_path(path, base) : File.expand_path(path)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Converts an absolute path to a path relative to the given root
|
|
79
|
+
#
|
|
80
|
+
# @param path [String] absolute path to relativize
|
|
81
|
+
# @param root [String] root directory for relativization
|
|
82
|
+
# @return [String] relative path or original path if conversion fails
|
|
83
|
+
def self.relativize(path, root)
|
|
84
|
+
return path if path.nil? || path.empty? || root.nil? || root.empty?
|
|
85
|
+
|
|
86
|
+
# Only expand relative paths against root; absolute paths expand without base
|
|
87
|
+
abs_path = absolute?(path) ? expand(path) : expand(path, root)
|
|
88
|
+
abs_root = expand(root)
|
|
89
|
+
|
|
90
|
+
# Check if path is within root using normalized comparison
|
|
91
|
+
# Derive case-sensitivity from root's volume for accurate cross-volume handling
|
|
92
|
+
return path unless normalized_start_with?(abs_path, abs_root, root: abs_root)
|
|
93
|
+
|
|
94
|
+
# Normalize paths before calling relative_path_from to handle case-insensitive
|
|
95
|
+
# volumes and mixed separators. This ensures Pathname can correctly compute
|
|
96
|
+
# the relative path even when the input paths have different casings or separators.
|
|
97
|
+
# On case-insensitive volumes, normalize case as well so Pathname recognizes them as the same path.
|
|
98
|
+
# Derive case-sensitivity from root's volume
|
|
99
|
+
case_sensitive = begin
|
|
100
|
+
VolumeCaseSensitivity.volume_case_sensitive?(abs_root)
|
|
101
|
+
rescue SystemCallError, IOError
|
|
102
|
+
# If we can't detect case sensitivity, assume case-insensitive to be conservative
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
normalized_path = normalize(abs_path, normalize_case: !case_sensitive, root: abs_root)
|
|
106
|
+
normalized_root = normalize(abs_root, normalize_case: !case_sensitive, root: abs_root)
|
|
107
|
+
|
|
108
|
+
relative = Pathname.new(normalized_path)
|
|
109
|
+
.relative_path_from(Pathname.new(normalized_root))
|
|
110
|
+
.to_s
|
|
111
|
+
|
|
112
|
+
# Preserve original casing from abs_path by mapping normalized components back
|
|
113
|
+
if !case_sensitive && relative != '.'
|
|
114
|
+
preserve_original_casing(relative, abs_path, abs_root)
|
|
115
|
+
else
|
|
116
|
+
relative
|
|
117
|
+
end
|
|
118
|
+
rescue ArgumentError
|
|
119
|
+
# Path is on a different drive or cannot be made relative
|
|
120
|
+
path
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Checks if a path is absolute
|
|
124
|
+
#
|
|
125
|
+
# @param path [String] path to check
|
|
126
|
+
# @return [Boolean] true if path is absolute
|
|
127
|
+
def self.absolute?(path)
|
|
128
|
+
return false if path.nil? || path.empty?
|
|
129
|
+
|
|
130
|
+
# Check for Windows drive paths (C:/, D:/, etc.)
|
|
131
|
+
return true if path.match?(/^[A-Za-z]:[\/\\]/)
|
|
132
|
+
|
|
133
|
+
Pathname.new(path).absolute?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Checks if a path is relative
|
|
137
|
+
#
|
|
138
|
+
# @param path [String] path to check
|
|
139
|
+
# @return [Boolean] true if path is relative
|
|
140
|
+
def self.relative?(path)
|
|
141
|
+
!absolute?(path)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Checks if a path is within a given root directory
|
|
145
|
+
#
|
|
146
|
+
# @param path [String] path to check
|
|
147
|
+
# @param root [String] root directory
|
|
148
|
+
# @return [Boolean] true if path is within root
|
|
149
|
+
def self.within_root?(path, root)
|
|
150
|
+
return false if path.nil? || root.nil?
|
|
151
|
+
|
|
152
|
+
abs_path = expand(path)
|
|
153
|
+
abs_root = expand(root)
|
|
154
|
+
|
|
155
|
+
normalized_start_with?(abs_path, abs_root, root: abs_root)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Extracts basename from a path, handling normalization
|
|
159
|
+
#
|
|
160
|
+
# @param path [String] path to extract basename from
|
|
161
|
+
# @param options [Hash] options passed to normalize
|
|
162
|
+
# @return [String] basename
|
|
163
|
+
def self.basename(path, options = {})
|
|
164
|
+
return '' if path.nil? || path.empty?
|
|
165
|
+
|
|
166
|
+
normalize(path, options).split('/').last
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Joins path components using platform-appropriate separators
|
|
170
|
+
#
|
|
171
|
+
# @param components [Array<String>] path components
|
|
172
|
+
# @return [String] joined path
|
|
173
|
+
def self.join(*components)
|
|
174
|
+
File.join(*components)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Detects whether the volume at the given path is case-sensitive.
|
|
178
|
+
# Delegates to VolumeCaseSensitivity module for implementation.
|
|
179
|
+
#
|
|
180
|
+
# @param path [String, nil] directory path to test (defaults to current directory)
|
|
181
|
+
# @return [Boolean] true if case-sensitive, false if case-insensitive or on error
|
|
182
|
+
def self.volume_case_sensitive?(path = nil)
|
|
183
|
+
VolumeCaseSensitivity.volume_case_sensitive?(path)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Clears the volume case sensitivity cache (useful for testing)
|
|
187
|
+
#
|
|
188
|
+
# @return [void]
|
|
189
|
+
def self.clear_volume_case_sensitivity_cache
|
|
190
|
+
VolumeCaseSensitivity.clear_cache
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Returns root path with trailing separator for prefix matching
|
|
194
|
+
#
|
|
195
|
+
# @param root [String] root path
|
|
196
|
+
# @return [String] root with trailing separator
|
|
197
|
+
def self.root_prefix(root)
|
|
198
|
+
return '' if root.nil? || root.empty?
|
|
199
|
+
|
|
200
|
+
root.end_with?(File::SEPARATOR) ? root : "#{root}#{File::SEPARATOR}"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Preserves original casing from the source path when creating a relative path
|
|
204
|
+
#
|
|
205
|
+
# @param relative_path [String] normalized relative path
|
|
206
|
+
# @param source_path [String] original source path with original casing
|
|
207
|
+
# @param root_path [String] root path
|
|
208
|
+
# @return [String] relative path with original casing preserved
|
|
209
|
+
def self.preserve_original_casing(relative_path, source_path, root_path)
|
|
210
|
+
# Split paths into components
|
|
211
|
+
relative_components = relative_path.split('/')
|
|
212
|
+
source_components = normalize(source_path, normalize_case: false, root: root_path).split('/')
|
|
213
|
+
root_components = normalize(root_path, normalize_case: false, root: root_path).split('/')
|
|
214
|
+
|
|
215
|
+
# Skip root components to get to the relative part
|
|
216
|
+
relative_start_index = root_components.length
|
|
217
|
+
|
|
218
|
+
# Map each normalized component back to its original casing
|
|
219
|
+
original_components = relative_components.map.with_index do |_component, index|
|
|
220
|
+
source_index = relative_start_index + index
|
|
221
|
+
source_components[source_index] || relative_components[index]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
original_components.join('/')
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Checks if a path starts with a prefix using normalized comparison
|
|
228
|
+
# to handle case-insensitive volumes and mixed separators
|
|
229
|
+
#
|
|
230
|
+
# @param path [String] path to check
|
|
231
|
+
# @param prefix [String] prefix to match against
|
|
232
|
+
# @param root [String, nil] root directory for determining volume case-sensitivity
|
|
233
|
+
# @return [Boolean] true if path starts with prefix (after normalization)
|
|
234
|
+
def self.normalized_start_with?(path, prefix, root: nil)
|
|
235
|
+
return false if path.nil? || prefix.nil? || prefix.empty?
|
|
236
|
+
|
|
237
|
+
# Normalize both paths for comparison (case + separators)
|
|
238
|
+
# If root is provided, derive case-sensitivity from root's volume
|
|
239
|
+
case_sensitive = begin
|
|
240
|
+
if root
|
|
241
|
+
VolumeCaseSensitivity.volume_case_sensitive?(root)
|
|
242
|
+
else
|
|
243
|
+
VolumeCaseSensitivity.volume_case_sensitive?
|
|
244
|
+
end
|
|
245
|
+
rescue SystemCallError, IOError
|
|
246
|
+
# If we can't detect case sensitivity, assume case-insensitive to be conservative
|
|
247
|
+
false
|
|
248
|
+
end
|
|
249
|
+
normalized_path = normalize(path, normalize_case: !case_sensitive, root: root)
|
|
250
|
+
normalized_prefix = normalize(prefix, normalize_case: !case_sensitive, root: root)
|
|
251
|
+
|
|
252
|
+
# Check if normalized path starts with normalized prefix
|
|
253
|
+
# AND ensure we have proper path boundary (either exact match or followed by separator)
|
|
254
|
+
return false unless normalized_path.start_with?(normalized_prefix)
|
|
255
|
+
|
|
256
|
+
# If exact match, return true
|
|
257
|
+
return true if normalized_path == normalized_prefix
|
|
258
|
+
|
|
259
|
+
# Otherwise, ensure character after prefix is a path separator
|
|
260
|
+
# (normalize converts all backslashes to forward slashes, so only check for /)
|
|
261
|
+
prefix_length = normalized_prefix.length
|
|
262
|
+
normalized_path[prefix_length] == '/'
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|